Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41807e78f | ||
|
|
06fe7d60e2 | ||
|
|
75f0f08c78 | ||
|
|
8fcbf78714 | ||
|
|
837e754946 | ||
|
|
ad86e1f55a | ||
|
|
c78564ea79 | ||
|
|
29f8f0d317 | ||
|
|
4461c77992 | ||
|
|
dc5f463e84 | ||
|
|
a92e59c693 | ||
|
|
54206c446f | ||
|
|
d93929a3b6 | ||
|
|
af6d1f4701 | ||
|
|
73757dad6b | ||
|
|
ec1c5c40e2 | ||
|
|
95f3304cae | ||
|
|
8301c3f2c3 | ||
|
|
581a2b41a8 | ||
|
|
5e397c06d2 | ||
|
|
02dc7ab6b3 | ||
|
|
b4451bf54a | ||
|
|
47b77528ce | ||
|
|
e9f12832fb | ||
|
|
b0a02e6870 | ||
|
|
f5e2e4e915 | ||
|
|
fbb8f01e1a | ||
|
|
b085f4feec | ||
|
|
3cffb34364 | ||
|
|
ffef4a4e3b | ||
|
|
0b24f2cd37 | ||
|
|
d80d0285f9 | ||
|
|
c8acd0227a | ||
|
|
105f244a4b | ||
|
|
8a80ded187 | ||
|
|
49b56f0a6e | ||
|
|
c634e906ea | ||
|
|
03d35f8fe1 | ||
|
|
a7b795d3cc | ||
|
|
f4ae25f8d3 | ||
|
|
2e4e71f459 | ||
|
|
479fd45cab | ||
|
|
9cac7e5a91 | ||
|
|
bb4f860f37 | ||
|
|
fed9d43fc5 | ||
|
|
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
@@ -19,7 +19,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
CARGO_MAKE_VERSION: "0.37.18"
|
||||
CLOUD_VERSION: 0.6.54-amd64
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
CARGO_MAKE_VERSION: "0.37.18"
|
||||
CLOUD_VERSION: 0.9.49-amd64
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
|
||||
@@ -19,7 +19,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CLOUD_VERSION: 0.9.49-amd64
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
|
||||
jobs:
|
||||
ubuntu-job:
|
||||
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
# Release Notes
|
||||
## Version 0.9.3 - 28/05/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
- Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions
|
||||
- AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews
|
||||
- Revamped Search Panel: A cleaner, smarter interface to help you search faster and more effectively
|
||||
- Custom Prompts: Load a database page as the source for your own custom AI prompts
|
||||
#### Bug Fixes
|
||||
- Fixed misalignment in database view after setting maxDocumentWidth
|
||||
- Centered embedded link when the site name is empty
|
||||
- Fixed issue where row observer was not clearing as expected
|
||||
- Fixed issue where workspace name reverted after being updated
|
||||
- Aligned checkbox icon with the first line of text
|
||||
### Mobile
|
||||
#### New Features
|
||||
- Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions
|
||||
- AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews
|
||||
- Revamped Search Tab: A redesigned interface that helps you find what you need more efficiently
|
||||
#### Bug Fixes
|
||||
- Fixed issue where font size reset after restarting the app
|
||||
|
||||
## 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.3"
|
||||
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);
|
||||
});
|
||||
}
|
||||
+3
-2
@@ -5,12 +5,13 @@ import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
|
||||
import 'share_menu_test.dart' as share_menu_test;
|
||||
import 'tabs_test.dart' as tabs_test;
|
||||
import 'workspace_icon_test.dart' as workspace_icon_test;
|
||||
import 'workspace_settings_test.dart' as workspace_settings_test;
|
||||
// import 'workspace_settings_test.dart' as workspace_settings_test;
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
workspace_settings_test.main();
|
||||
// Enable it after implementing the share feature
|
||||
// workspace_settings_test.main();
|
||||
share_menu_test.main();
|
||||
collaborative_workspace_test.main();
|
||||
change_name_and_icon_test.main();
|
||||
|
||||
+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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -168,6 +168,38 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('clear cells under field', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
|
||||
await tester.scrollToRight(find.byType(GridPage));
|
||||
|
||||
// edit the data
|
||||
await tester.editCell(
|
||||
rowIndex: 0,
|
||||
fieldType: FieldType.RichText,
|
||||
input: 'Hello',
|
||||
);
|
||||
await tester.editCell(
|
||||
rowIndex: 1,
|
||||
fieldType: FieldType.RichText,
|
||||
input: 'World',
|
||||
);
|
||||
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(find.text('World'), findsOneWidget);
|
||||
|
||||
// clear the cells
|
||||
await tester.tapGridFieldWithName('Name');
|
||||
await tester.tapClearCellsButton();
|
||||
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
|
||||
|
||||
expect(find.text('Hello'), findsNothing);
|
||||
expect(find.text('World'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('create list of fields', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
+84
@@ -366,5 +366,89 @@ 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,6 +1,9 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/home.dart';
|
||||
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_ask_ai_entrance.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_page.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_result.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_textfield.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -56,4 +59,35 @@ void main() {
|
||||
expect(cancelButton, findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('tap to search page and back to home', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
|
||||
/// go to search page
|
||||
final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m);
|
||||
expect(find.byType(MobileSearchScreen), findsNothing);
|
||||
await tester.tapButton(searchButton);
|
||||
expect(find.byType(MobileSearchScreen), findsOneWidget);
|
||||
|
||||
/// back to home page
|
||||
final backButton = find.byFlowySvg(FlowySvgs.search_page_arrow_left_m);
|
||||
expect(backButton, findsOneWidget);
|
||||
expect(find.byType(MobileHomeScreen), findsNothing);
|
||||
await tester.tapButton(backButton);
|
||||
expect(find.byType(MobileHomeScreen), findsOneWidget);
|
||||
|
||||
/// go to notification page
|
||||
final notificationButton = find.byFlowySvg(FlowySvgs.m_home_notification_m);
|
||||
expect(find.byType(MobileNotificationsScreenV2), findsNothing);
|
||||
await tester.tapButton(notificationButton);
|
||||
expect(find.byType(MobileNotificationsScreenV2), findsOneWidget);
|
||||
|
||||
/// go to search page
|
||||
await tester.tapButton(searchButton);
|
||||
expect(find.byType(MobileNotificationsScreenV2), findsNothing);
|
||||
expect(find.byType(MobileSearchScreen), findsOneWidget);
|
||||
await tester.tapButton(backButton);
|
||||
expect(find.byType(MobileNotificationsScreenV2), findsOneWidget);
|
||||
expect(find.byType(MobileSearchScreen), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -836,6 +836,14 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await tapButton(button);
|
||||
}
|
||||
|
||||
Future<void> tapClearCellsButton() async {
|
||||
final field = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is FieldActionCell && widget.action == FieldAction.clearData,
|
||||
);
|
||||
await tapButton(field);
|
||||
}
|
||||
|
||||
Future<void> tapRowDetailPageRowActionButton() async =>
|
||||
tapButton(find.byType(RowActionButton));
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ class MockAIRepository extends Mock implements AppFlowyAIService {
|
||||
String? objectId,
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
String? promptId,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
|
||||
@@ -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,11 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/easy_localiation_service.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
@@ -14,11 +19,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 {
|
||||
@@ -110,75 +116,55 @@ enum TextFormat {
|
||||
}
|
||||
|
||||
enum AiPromptCategory {
|
||||
@JsonValue("other")
|
||||
other,
|
||||
@JsonValue("development")
|
||||
development,
|
||||
@JsonValue("writing")
|
||||
writing,
|
||||
@JsonValue("healthAndFitness")
|
||||
healthAndFitness,
|
||||
@JsonValue("business")
|
||||
business,
|
||||
@JsonValue("marketing")
|
||||
marketing,
|
||||
@JsonValue("travel")
|
||||
travel,
|
||||
@JsonValue("contentSeo")
|
||||
contentSeo,
|
||||
@JsonValue("emailMarketing")
|
||||
emailMarketing,
|
||||
@JsonValue("paidAds")
|
||||
paidAds,
|
||||
@JsonValue("prCommunication")
|
||||
prCommunication,
|
||||
@JsonValue("recruiting")
|
||||
recruiting,
|
||||
@JsonValue("sales")
|
||||
sales,
|
||||
@JsonValue("socialMedia")
|
||||
socialMedia,
|
||||
@JsonValue("strategy")
|
||||
strategy,
|
||||
@JsonValue("caseStudies")
|
||||
caseStudies,
|
||||
@JsonValue("salesCopy")
|
||||
salesCopy,
|
||||
@JsonValue("education")
|
||||
education,
|
||||
@JsonValue("work")
|
||||
work,
|
||||
@JsonValue("podcastProduction")
|
||||
podcastProduction,
|
||||
@JsonValue("copyWriting")
|
||||
copyWriting,
|
||||
@JsonValue("customerSuccess")
|
||||
customerSuccess;
|
||||
|
||||
String get i18n {
|
||||
String get i18n => token.tr();
|
||||
|
||||
String get token {
|
||||
return switch (this) {
|
||||
other => LocaleKeys.ai_customPrompt_others.tr(),
|
||||
development => LocaleKeys.ai_customPrompt_development.tr(),
|
||||
writing => LocaleKeys.ai_customPrompt_writing.tr(),
|
||||
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
|
||||
business => LocaleKeys.ai_customPrompt_business.tr(),
|
||||
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
|
||||
travel => LocaleKeys.ai_customPrompt_travel.tr(),
|
||||
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
|
||||
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
|
||||
paidAds => LocaleKeys.ai_customPrompt_paidAds.tr(),
|
||||
prCommunication => LocaleKeys.ai_customPrompt_prCommunication.tr(),
|
||||
recruiting => LocaleKeys.ai_customPrompt_recruiting.tr(),
|
||||
sales => LocaleKeys.ai_customPrompt_sales.tr(),
|
||||
socialMedia => LocaleKeys.ai_customPrompt_socialMedia.tr(),
|
||||
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
|
||||
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
|
||||
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
|
||||
education => LocaleKeys.ai_customPrompt_education.tr(),
|
||||
work => LocaleKeys.ai_customPrompt_work.tr(),
|
||||
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction.tr(),
|
||||
copyWriting => LocaleKeys.ai_customPrompt_copyWriting.tr(),
|
||||
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess.tr(),
|
||||
other => LocaleKeys.ai_customPrompt_others,
|
||||
development => LocaleKeys.ai_customPrompt_development,
|
||||
writing => LocaleKeys.ai_customPrompt_writing,
|
||||
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness,
|
||||
business => LocaleKeys.ai_customPrompt_business,
|
||||
marketing => LocaleKeys.ai_customPrompt_marketing,
|
||||
travel => LocaleKeys.ai_customPrompt_travel,
|
||||
contentSeo => LocaleKeys.ai_customPrompt_contentSeo,
|
||||
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing,
|
||||
paidAds => LocaleKeys.ai_customPrompt_paidAds,
|
||||
prCommunication => LocaleKeys.ai_customPrompt_prCommunication,
|
||||
recruiting => LocaleKeys.ai_customPrompt_recruiting,
|
||||
sales => LocaleKeys.ai_customPrompt_sales,
|
||||
socialMedia => LocaleKeys.ai_customPrompt_socialMedia,
|
||||
strategy => LocaleKeys.ai_customPrompt_strategy,
|
||||
caseStudies => LocaleKeys.ai_customPrompt_caseStudies,
|
||||
salesCopy => LocaleKeys.ai_customPrompt_salesCopy,
|
||||
education => LocaleKeys.ai_customPrompt_education,
|
||||
work => LocaleKeys.ai_customPrompt_work,
|
||||
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction,
|
||||
copyWriting => LocaleKeys.ai_customPrompt_copyWriting,
|
||||
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -192,8 +178,38 @@ class AiPrompt extends Equatable {
|
||||
required this.category,
|
||||
required this.example,
|
||||
required this.isFeatured,
|
||||
required this.isCustom,
|
||||
});
|
||||
|
||||
factory AiPrompt.fromPB(CustomPromptPB pb) {
|
||||
final map = _buildCategoryNameMap();
|
||||
final categories = pb.category
|
||||
.split(',')
|
||||
.map((categoryName) => categoryName.trim())
|
||||
.map(
|
||||
(categoryName) {
|
||||
final entry = map.entries.firstWhereOrNull(
|
||||
(entry) =>
|
||||
entry.value.$1 == categoryName ||
|
||||
entry.value.$2 == categoryName,
|
||||
);
|
||||
return entry?.key ?? AiPromptCategory.other;
|
||||
},
|
||||
)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
return AiPrompt(
|
||||
id: pb.id,
|
||||
name: pb.name,
|
||||
content: pb.content,
|
||||
category: categories,
|
||||
example: pb.example,
|
||||
isFeatured: false,
|
||||
isCustom: true,
|
||||
);
|
||||
}
|
||||
|
||||
factory AiPrompt.fromJson(Map<String, dynamic> json) =>
|
||||
_$AiPromptFromJson(json);
|
||||
|
||||
@@ -202,16 +218,169 @@ class AiPrompt extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String content;
|
||||
@JsonKey(
|
||||
unknownEnumValue: AiPromptCategory.other,
|
||||
defaultValue: AiPromptCategory.other,
|
||||
)
|
||||
final AiPromptCategory category;
|
||||
@JsonKey(fromJson: _categoryFromJson)
|
||||
final List<AiPromptCategory> category;
|
||||
@JsonKey(defaultValue: "")
|
||||
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];
|
||||
|
||||
static Map<AiPromptCategory, (String, String)> _buildCategoryNameMap() {
|
||||
final service = getIt<EasyLocalizationService>();
|
||||
return {
|
||||
for (final category in AiPromptCategory.values)
|
||||
category: (
|
||||
service.getFallbackTranslation(category.token),
|
||||
service.getFallbackTranslation(category.token),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static List<AiPromptCategory> _categoryFromJson(dynamic json) {
|
||||
if (json is String) {
|
||||
return json
|
||||
.split(',')
|
||||
.map((categoryName) => categoryName.trim())
|
||||
.map(
|
||||
(categoryName) => $enumDecode(
|
||||
_aiPromptCategoryEnumMap,
|
||||
categoryName,
|
||||
unknownValue: AiPromptCategory.other,
|
||||
),
|
||||
)
|
||||
.toSet()
|
||||
.toList();
|
||||
}
|
||||
|
||||
return [AiPromptCategory.other];
|
||||
}
|
||||
}
|
||||
|
||||
const _aiPromptCategoryEnumMap = {
|
||||
AiPromptCategory.other: 'other',
|
||||
AiPromptCategory.development: 'development',
|
||||
AiPromptCategory.writing: 'writing',
|
||||
AiPromptCategory.healthAndFitness: 'healthAndFitness',
|
||||
AiPromptCategory.business: 'business',
|
||||
AiPromptCategory.marketing: 'marketing',
|
||||
AiPromptCategory.travel: 'travel',
|
||||
AiPromptCategory.contentSeo: 'contentSeo',
|
||||
AiPromptCategory.emailMarketing: 'emailMarketing',
|
||||
AiPromptCategory.paidAds: 'paidAds',
|
||||
AiPromptCategory.prCommunication: 'prCommunication',
|
||||
AiPromptCategory.recruiting: 'recruiting',
|
||||
AiPromptCategory.sales: 'sales',
|
||||
AiPromptCategory.socialMedia: 'socialMedia',
|
||||
AiPromptCategory.strategy: 'strategy',
|
||||
AiPromptCategory.caseStudies: 'caseStudies',
|
||||
AiPromptCategory.salesCopy: 'salesCopy',
|
||||
AiPromptCategory.education: 'education',
|
||||
AiPromptCategory.work: 'work',
|
||||
AiPromptCategory.podcastProduction: 'podcastProduction',
|
||||
AiPromptCategory.copyWriting: 'copyWriting',
|
||||
AiPromptCategory.customerSuccess: 'customerSuccess',
|
||||
};
|
||||
|
||||
class CustomPromptDatabaseConfig extends Equatable {
|
||||
const CustomPromptDatabaseConfig({
|
||||
required this.view,
|
||||
required this.titleFieldId,
|
||||
required this.contentFieldId,
|
||||
required this.exampleFieldId,
|
||||
required this.categoryFieldId,
|
||||
});
|
||||
|
||||
factory CustomPromptDatabaseConfig.fromAiPB(
|
||||
CustomPromptDatabaseConfigurationPB pb,
|
||||
ViewPB view,
|
||||
) {
|
||||
final config = CustomPromptDatabaseConfig(
|
||||
view: view,
|
||||
titleFieldId: pb.titleFieldId,
|
||||
contentFieldId: pb.contentFieldId,
|
||||
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
|
||||
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
factory CustomPromptDatabaseConfig.fromDbPB(
|
||||
CustomPromptDatabaseConfigPB pb,
|
||||
ViewPB view,
|
||||
) {
|
||||
final config = CustomPromptDatabaseConfig(
|
||||
view: view,
|
||||
titleFieldId: pb.titleFieldId,
|
||||
contentFieldId: pb.contentFieldId,
|
||||
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
|
||||
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
final ViewPB view;
|
||||
final String titleFieldId;
|
||||
final String contentFieldId;
|
||||
final String? exampleFieldId;
|
||||
final String? categoryFieldId;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[view.id, titleFieldId, contentFieldId, exampleFieldId, categoryFieldId];
|
||||
|
||||
CustomPromptDatabaseConfig copyWith({
|
||||
ViewPB? view,
|
||||
String? titleFieldId,
|
||||
String? contentFieldId,
|
||||
String? exampleFieldId,
|
||||
String? categoryFieldId,
|
||||
}) {
|
||||
return CustomPromptDatabaseConfig(
|
||||
view: view ?? this.view,
|
||||
titleFieldId: titleFieldId ?? this.titleFieldId,
|
||||
contentFieldId: contentFieldId ?? this.contentFieldId,
|
||||
exampleFieldId: exampleFieldId ?? this.exampleFieldId,
|
||||
categoryFieldId: categoryFieldId ?? this.categoryFieldId,
|
||||
);
|
||||
}
|
||||
|
||||
CustomPromptDatabaseConfigurationPB toAiPB() {
|
||||
final payload = CustomPromptDatabaseConfigurationPB.create()
|
||||
..viewId = view.id
|
||||
..titleFieldId = titleFieldId
|
||||
..contentFieldId = contentFieldId;
|
||||
|
||||
if (exampleFieldId != null) {
|
||||
payload.exampleFieldId = exampleFieldId!;
|
||||
}
|
||||
if (categoryFieldId != null) {
|
||||
payload.categoryFieldId = categoryFieldId!;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
CustomPromptDatabaseConfigPB toDbPB() {
|
||||
final payload = CustomPromptDatabaseConfigPB.create()
|
||||
..viewId = view.id
|
||||
..titleFieldId = titleFieldId
|
||||
..contentFieldId = contentFieldId;
|
||||
|
||||
if (exampleFieldId != null) {
|
||||
payload.exampleFieldId = exampleFieldId!;
|
||||
}
|
||||
if (categoryFieldId != null) {
|
||||
payload.categoryFieldId = categoryFieldId!;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/database/domain/field_service.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'ai_prompt_database_selector_cubit.freezed.dart';
|
||||
|
||||
class AiPromptDatabaseSelectorCubit
|
||||
extends Cubit<AiPromptDatabaseSelectorState> {
|
||||
AiPromptDatabaseSelectorCubit({
|
||||
required CustomPromptDatabaseConfig? configuration,
|
||||
}) : super(AiPromptDatabaseSelectorState.loading()) {
|
||||
_init(configuration);
|
||||
}
|
||||
|
||||
void _init(CustomPromptDatabaseConfig? config) async {
|
||||
if (config == null) {
|
||||
emit(AiPromptDatabaseSelectorState.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
final fields = await _getFields(config.view.id);
|
||||
|
||||
if (fields == null) {
|
||||
emit(AiPromptDatabaseSelectorState.empty());
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
AiPromptDatabaseSelectorState.selected(
|
||||
config: config,
|
||||
fields: fields,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectDatabaseView(String viewId) async {
|
||||
final configuration = await _testDatabase(viewId);
|
||||
|
||||
if (configuration == null) {
|
||||
final stateCopy = state;
|
||||
emit(AiPromptDatabaseSelectorState.invalidDatabase());
|
||||
emit(stateCopy);
|
||||
return;
|
||||
}
|
||||
|
||||
final databaseView = await AiPromptSelectorCubit.getDatabaseView(viewId);
|
||||
final fields = await _getFields(viewId);
|
||||
|
||||
if (databaseView == null || fields == null) {
|
||||
final stateCopy = state;
|
||||
emit(AiPromptDatabaseSelectorState.invalidDatabase());
|
||||
emit(stateCopy);
|
||||
return;
|
||||
}
|
||||
|
||||
final config = CustomPromptDatabaseConfig.fromDbPB(
|
||||
configuration,
|
||||
databaseView,
|
||||
);
|
||||
|
||||
emit(
|
||||
AiPromptDatabaseSelectorState.selected(
|
||||
config: config,
|
||||
fields: fields,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectContentField(String fieldId) {
|
||||
final state = this.state;
|
||||
if (state is! _Selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final config = state.config.copyWith(
|
||||
contentFieldId: fieldId,
|
||||
);
|
||||
|
||||
emit(
|
||||
AiPromptDatabaseSelectorState.selected(
|
||||
config: config,
|
||||
fields: state.fields,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectExampleField(String? fieldId) {
|
||||
final state = this.state;
|
||||
if (state is! _Selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final config = CustomPromptDatabaseConfig(
|
||||
exampleFieldId: fieldId,
|
||||
view: state.config.view,
|
||||
titleFieldId: state.config.titleFieldId,
|
||||
contentFieldId: state.config.contentFieldId,
|
||||
categoryFieldId: state.config.categoryFieldId,
|
||||
);
|
||||
|
||||
emit(
|
||||
AiPromptDatabaseSelectorState.selected(
|
||||
config: config,
|
||||
fields: state.fields,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectCategoryField(String? fieldId) {
|
||||
final state = this.state;
|
||||
if (state is! _Selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final config = CustomPromptDatabaseConfig(
|
||||
categoryFieldId: fieldId,
|
||||
view: state.config.view,
|
||||
titleFieldId: state.config.titleFieldId,
|
||||
contentFieldId: state.config.contentFieldId,
|
||||
exampleFieldId: state.config.exampleFieldId,
|
||||
);
|
||||
|
||||
emit(
|
||||
AiPromptDatabaseSelectorState.selected(
|
||||
config: config,
|
||||
fields: state.fields,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<FieldPB>?> _getFields(String viewId) {
|
||||
return FieldBackendService.getFields(viewId: viewId).toNullable();
|
||||
}
|
||||
|
||||
Future<CustomPromptDatabaseConfigPB?> _testDatabase(
|
||||
String viewId,
|
||||
) {
|
||||
return DatabaseEventTestCustomPromptDatabaseConfiguration(
|
||||
DatabaseViewIdPB(value: viewId),
|
||||
).send().toNullable();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class AiPromptDatabaseSelectorState with _$AiPromptDatabaseSelectorState {
|
||||
const factory AiPromptDatabaseSelectorState.loading() = _Loading;
|
||||
|
||||
const factory AiPromptDatabaseSelectorState.empty() = _Empty;
|
||||
|
||||
const factory AiPromptDatabaseSelectorState.selected({
|
||||
required CustomPromptDatabaseConfig config,
|
||||
required List<FieldPB> fields,
|
||||
}) = _Selected;
|
||||
|
||||
const factory AiPromptDatabaseSelectorState.invalidDatabase() =
|
||||
_InvalidDatabase;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
|
||||
final AIModelStateNotifier aiModelStateNotifier;
|
||||
|
||||
String? promptId;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await aiModelStateNotifier.dispose();
|
||||
@@ -88,7 +90,11 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
updatePromptId: (promptId) {
|
||||
this.promptId = promptId;
|
||||
},
|
||||
clearMetadata: () {
|
||||
promptId = null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
attachedFiles: [],
|
||||
@@ -151,6 +157,8 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
|
||||
const factory AIPromptInputEvent.updateMentionedViews(List<ViewPB> views) =
|
||||
_UpdateMentionedViews;
|
||||
const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata;
|
||||
const factory AIPromptInputEvent.updatePromptId(String promptId) =
|
||||
_UpdatePromptId;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../plugins/trash/application/trash_service.dart';
|
||||
import 'ai_entities.dart';
|
||||
import 'appflowy_ai_service.dart';
|
||||
|
||||
@@ -14,7 +22,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
}) : _aiService = aiService ?? AppFlowyAIService(),
|
||||
super(AiPromptSelectorState.loading()) {
|
||||
filterTextController.addListener(_filterTextChanged);
|
||||
_loadPrompts();
|
||||
_init();
|
||||
}
|
||||
|
||||
final AppFlowyAIService _aiService;
|
||||
@@ -27,33 +35,146 @@ 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,
|
||||
databaseConfig: null,
|
||||
isLoadingCustomPrompts: true,
|
||||
selectedCategory: null,
|
||||
favoritePrompts: [],
|
||||
),
|
||||
);
|
||||
|
||||
loadCustomPrompts();
|
||||
}
|
||||
|
||||
void selectFeaturedCategory() {
|
||||
void loadCustomPrompts() {
|
||||
state.maybeMap(
|
||||
ready: (readyState) async {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: true),
|
||||
);
|
||||
|
||||
CustomPromptDatabaseConfig? configuration = readyState.databaseConfig;
|
||||
if (configuration == null) {
|
||||
final configResult =
|
||||
await AIEventGetCustomPromptDatabaseConfiguration()
|
||||
.send()
|
||||
.toNullable();
|
||||
if (configResult != null) {
|
||||
final view = await getDatabaseView(configResult.viewId);
|
||||
if (view != null) {
|
||||
configuration = CustomPromptDatabaseConfig.fromAiPB(
|
||||
configResult,
|
||||
view,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final view = await getDatabaseView(configuration.view.id);
|
||||
if (view != null) {
|
||||
configuration = configuration.copyWith(view: view);
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration == null) {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
availablePrompts.removeWhere((prompt) => prompt.isCustom);
|
||||
|
||||
final customPrompts =
|
||||
await _aiService.getDatabasePrompts(configuration.toDbPB());
|
||||
|
||||
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,
|
||||
databaseConfig: configuration,
|
||||
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(),
|
||||
databaseConfig: configuration,
|
||||
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 +185,24 @@ 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);
|
||||
: availablePrompts
|
||||
.where((prompt) => prompt.category.contains(category));
|
||||
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 +237,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 +249,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 +260,74 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
|
||||
);
|
||||
}
|
||||
|
||||
void updateCustomPromptDatabaseConfiguration(
|
||||
CustomPromptDatabaseConfig configuration,
|
||||
) async {
|
||||
state.maybeMap(
|
||||
ready: (readyState) async {
|
||||
emit(
|
||||
readyState.copyWith(isLoadingCustomPrompts: true),
|
||||
);
|
||||
|
||||
final customPrompts =
|
||||
await _aiService.getDatabasePrompts(configuration.toDbPB());
|
||||
|
||||
if (customPrompts == null) {
|
||||
emit(AiPromptSelectorState.invalidDatabase());
|
||||
emit(readyState);
|
||||
return;
|
||||
}
|
||||
|
||||
availablePrompts
|
||||
..removeWhere((prompt) => prompt.isCustom)
|
||||
..addAll(customPrompts);
|
||||
|
||||
await AIEventSetCustomPromptDatabaseConfiguration(
|
||||
configuration.toAiPB(),
|
||||
).send().onFailure(Log.error);
|
||||
|
||||
final prompts = _getPromptsByCategory(readyState);
|
||||
final visiblePrompts = _getFilteredPrompts(prompts);
|
||||
final selectedPromptId = _getVisibleSelectedPrompt(
|
||||
visiblePrompts,
|
||||
readyState.selectedPromptId,
|
||||
);
|
||||
emit(
|
||||
readyState.copyWith(
|
||||
visiblePrompts: visiblePrompts.toList(),
|
||||
selectedPromptId: selectedPromptId,
|
||||
databaseConfig: configuration,
|
||||
isLoadingCustomPrompts: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => {},
|
||||
);
|
||||
}
|
||||
|
||||
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 +335,53 @@ 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.contains(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;
|
||||
}
|
||||
|
||||
static Future<ViewPB?> getDatabaseView(String viewId) async {
|
||||
final view = await ViewBackendService.getView(viewId).toNullable();
|
||||
|
||||
if (view != null) {
|
||||
return view;
|
||||
}
|
||||
|
||||
final trashViews = await TrashService().readTrash().toNullable();
|
||||
final trashedItem =
|
||||
trashViews?.items.firstWhereOrNull((element) => element.id == viewId);
|
||||
|
||||
if (trashedItem == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ViewPB()
|
||||
..id = trashedItem.id
|
||||
..name = trashedItem.name;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -175,13 +390,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 CustomPromptDatabaseConfig? databaseConfig,
|
||||
}) = _AiPromptSelectorReadyState;
|
||||
|
||||
bool get isLoading => this is _AiPromptSelectorLoadingState;
|
||||
@@ -192,9 +412,4 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
|
||||
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
AiPromptCategory? get selectedCategory => maybeMap(
|
||||
ready: (state) => state.selectedCategory,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operati
|
||||
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-ai/protobuf.dart'
|
||||
hide CustomPromptDatabaseConfigurationPB;
|
||||
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;
|
||||
@@ -27,6 +29,7 @@ abstract class AIRepository {
|
||||
String? objectId,
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
String? promptId,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
@@ -41,6 +44,10 @@ abstract class AIRepository {
|
||||
|
||||
Future<List<AiPrompt>> getBuiltInPrompts();
|
||||
|
||||
Future<List<AiPrompt>?> getDatabasePrompts(
|
||||
CustomPromptDatabaseConfigPB config,
|
||||
);
|
||||
|
||||
void updateFavoritePrompts(List<String> promptIds);
|
||||
}
|
||||
|
||||
@@ -50,6 +57,7 @@ class AppFlowyAIService implements AIRepository {
|
||||
String? objectId,
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
String? promptId,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
@@ -76,6 +84,7 @@ class AppFlowyAIService implements AIRepository {
|
||||
text: text,
|
||||
completionType: completionType,
|
||||
format: format?.toPB(),
|
||||
promptId: promptId,
|
||||
streamPort: fixnum.Int64(stream.nativePort),
|
||||
objectId: objectId ?? '',
|
||||
ragIds: [
|
||||
@@ -117,6 +126,20 @@ class AppFlowyAIService implements AIRepository {
|
||||
return prompts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<AiPrompt>?> getDatabasePrompts(
|
||||
CustomPromptDatabaseConfigPB config,
|
||||
) async {
|
||||
return DatabaseEventGetDatabaseCustomPrompts(config).send().fold(
|
||||
(databasePromptsPB) =>
|
||||
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: [],
|
||||
);
|
||||
+153
-86
@@ -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,62 @@ 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))
|
||||
..remove(AiPromptCategory.other)
|
||||
..add(AiPromptCategory.other);
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
Widget _buildCategoryItem(
|
||||
BuildContext context,
|
||||
AiPromptCategory? category,
|
||||
@@ -88,18 +87,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) {
|
||||
@@ -123,7 +129,7 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
|
||||
return theme.fillColorScheme.themeSelect;
|
||||
}
|
||||
if (isHovering) {
|
||||
return theme.fillColorScheme.primaryAlpha5;
|
||||
return theme.fillColorScheme.contentHover;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
@@ -131,6 +137,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.contentHover;
|
||||
}
|
||||
return Colors.transparent;
|
||||
},
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AiPromptCategoryItem extends StatelessWidget {
|
||||
const AiPromptCategoryItem({
|
||||
super.key,
|
||||
@@ -143,41 +204,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.contentHover;
|
||||
}
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+626
@@ -0,0 +1,626 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/ai/service/ai_prompt_database_selector_cubit.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.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/workspace/presentation/widgets/dialog_v2.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:expandable/expandable.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
Future<CustomPromptDatabaseConfig?> changeCustomPromptDatabaseConfig(
|
||||
BuildContext context, {
|
||||
CustomPromptDatabaseConfig? config,
|
||||
}) async {
|
||||
return showDialog<CustomPromptDatabaseConfig?>(
|
||||
context: context,
|
||||
builder: (_) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(
|
||||
value: context.read<UserWorkspaceBloc>(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => AiPromptDatabaseSelectorCubit(
|
||||
configuration: config,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const AiPromptDatabaseModal(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class AiPromptDatabaseModal extends StatefulWidget {
|
||||
const AiPromptDatabaseModal({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AiPromptDatabaseModal> createState() => _AiPromptDatabaseModalState();
|
||||
}
|
||||
|
||||
class _AiPromptDatabaseModalState extends State<AiPromptDatabaseModal> {
|
||||
final expandableController = ExpandableController(initialExpanded: false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
expandableController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocListener<AiPromptDatabaseSelectorCubit,
|
||||
AiPromptDatabaseSelectorState>(
|
||||
listener: (context, state) {
|
||||
state.maybeMap(
|
||||
invalidDatabase: (_) {
|
||||
showSimpleAFDialog(
|
||||
context: context,
|
||||
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
|
||||
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
|
||||
primaryAction: (
|
||||
LocaleKeys.button_ok.tr(),
|
||||
(context) {},
|
||||
),
|
||||
);
|
||||
},
|
||||
empty: (_) => expandableController.expanded = false,
|
||||
selected: (_) => expandableController.expanded = true,
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
child: AFModal(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 450,
|
||||
maxHeight: 400,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AFModalHeader(
|
||||
leading: Text(
|
||||
LocaleKeys.ai_customPrompt_configureDatabase.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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: AFModalBody(
|
||||
child: ExpandablePanel(
|
||||
controller: expandableController,
|
||||
theme: ExpandableThemeData(
|
||||
tapBodyToCollapse: false,
|
||||
hasIcon: false,
|
||||
tapBodyToExpand: false,
|
||||
tapHeaderToExpand: false,
|
||||
),
|
||||
header: const _Header(),
|
||||
collapsed: const SizedBox.shrink(),
|
||||
expanded: const _Expanded(),
|
||||
),
|
||||
),
|
||||
),
|
||||
AFModalFooter(
|
||||
trailing: [
|
||||
AFOutlinedButton.normal(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AFFilledButton.primary(
|
||||
onTap: () {
|
||||
final config = context
|
||||
.read<AiPromptDatabaseSelectorCubit>()
|
||||
.state
|
||||
.maybeMap(
|
||||
selected: (state) => state.config,
|
||||
orElse: () => null,
|
||||
);
|
||||
Navigator.of(context).pop(config);
|
||||
},
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Text(
|
||||
LocaleKeys.button_done.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.onFill,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatefulWidget {
|
||||
const _Header();
|
||||
|
||||
@override
|
||||
State<_Header> createState() => _HeaderState();
|
||||
}
|
||||
|
||||
class _HeaderState extends State<_Header> {
|
||||
final popoverController = AFPopoverController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
popoverController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocBuilder<AiPromptDatabaseSelectorCubit,
|
||||
AiPromptDatabaseSelectorState>(
|
||||
builder: (context, state) {
|
||||
bool showNothing = false;
|
||||
String? viewName;
|
||||
state.maybeMap(
|
||||
empty: (_) {
|
||||
showNothing = false;
|
||||
viewName = null;
|
||||
},
|
||||
selected: (selectedState) {
|
||||
showNothing = false;
|
||||
viewName = selectedState.config.view.nameOrDefault;
|
||||
},
|
||||
orElse: () {
|
||||
showNothing = true;
|
||||
viewName = null;
|
||||
},
|
||||
);
|
||||
|
||||
if (showNothing) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.m,
|
||||
vertical: theme.spacing.xl,
|
||||
),
|
||||
child: Row(
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: 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 AFPopover(
|
||||
controller: popoverController,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
borderRadius:
|
||||
BorderRadius.circular(theme.borderRadius.l),
|
||||
border: Border.all(
|
||||
color: theme.borderColorScheme.primary,
|
||||
),
|
||||
boxShadow: theme.shadow.medium,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
anchor: AFAnchor(
|
||||
childAlignment: Alignment.topCenter,
|
||||
overlayAlignment: Alignment.bottomCenter,
|
||||
offset: Offset(0, theme.spacing.xs),
|
||||
),
|
||||
popover: (context) {
|
||||
return _PopoverContent(
|
||||
onSelectViewItem: (item) {
|
||||
context
|
||||
.read<AiPromptDatabaseSelectorCubit>()
|
||||
.selectDatabaseView(item.view.id);
|
||||
popoverController.hide();
|
||||
},
|
||||
);
|
||||
},
|
||||
child: AFOutlinedButton.normal(
|
||||
onTap: () {
|
||||
context
|
||||
.read<ViewSelectorCubit>()
|
||||
.refreshSources(state.$1, state.$2);
|
||||
popoverController.toggle();
|
||||
},
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
viewName ??
|
||||
LocaleKeys
|
||||
.ai_customPrompt_selectDatabase
|
||||
.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
color: theme.iconColorScheme.primary,
|
||||
size: Size(12, 20),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 _Expanded extends StatelessWidget {
|
||||
const _Expanded();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocBuilder<AiPromptDatabaseSelectorCubit,
|
||||
AiPromptDatabaseSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
orElse: () => SizedBox.shrink(),
|
||||
selected: (selectedState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(theme.spacing.m),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: theme.spacing.m,
|
||||
children: [
|
||||
FieldSelector(
|
||||
title: LocaleKeys.ai_customPrompt_title.tr(),
|
||||
currentFieldId: selectedState.config.titleFieldId,
|
||||
isDisabled: true,
|
||||
fields: selectedState.fields,
|
||||
onSelect: (id) {},
|
||||
),
|
||||
FieldSelector(
|
||||
title: LocaleKeys.ai_customPrompt_content.tr(),
|
||||
currentFieldId: selectedState.config.contentFieldId,
|
||||
fields: selectedState.fields
|
||||
.where((f) => f.fieldType == FieldType.RichText)
|
||||
.toList(),
|
||||
onSelect: (id) {
|
||||
if (id != null) {
|
||||
context
|
||||
.read<AiPromptDatabaseSelectorCubit>()
|
||||
.selectContentField(id);
|
||||
}
|
||||
},
|
||||
),
|
||||
FieldSelector(
|
||||
title: LocaleKeys.ai_customPrompt_example.tr(),
|
||||
currentFieldId: selectedState.config.exampleFieldId,
|
||||
isOptional: true,
|
||||
fields: selectedState.fields
|
||||
.where((f) => f.fieldType == FieldType.RichText)
|
||||
.toList(),
|
||||
onSelect: (id) {
|
||||
context
|
||||
.read<AiPromptDatabaseSelectorCubit>()
|
||||
.selectExampleField(id);
|
||||
},
|
||||
),
|
||||
FieldSelector(
|
||||
title: LocaleKeys.ai_customPrompt_category.tr(),
|
||||
currentFieldId: selectedState.config.categoryFieldId,
|
||||
isOptional: true,
|
||||
fields: selectedState.fields
|
||||
.where(
|
||||
(f) =>
|
||||
f.fieldType == FieldType.RichText ||
|
||||
f.fieldType == FieldType.SingleSelect ||
|
||||
f.fieldType == FieldType.MultiSelect,
|
||||
)
|
||||
.toList(),
|
||||
onSelect: (id) {
|
||||
context
|
||||
.read<AiPromptDatabaseSelectorCubit>()
|
||||
.selectCategoryField(id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 ConstrainedBox(
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 300,
|
||||
height: 400,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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: (item) {
|
||||
if (item.view.isDocument || item.view.isSpace) {
|
||||
context.read<ViewSelectorCubit>().toggleIsExpanded(item, false);
|
||||
return;
|
||||
}
|
||||
widget.onSelectViewItem(item);
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FieldPBWrapper extends Equatable with AFDropDownMenuMixin {
|
||||
const _FieldPBWrapper(this.field);
|
||||
|
||||
final FieldPB field;
|
||||
|
||||
@override
|
||||
String get label => field.name;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [field.id];
|
||||
}
|
||||
|
||||
class FieldSelector extends StatelessWidget {
|
||||
const FieldSelector({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.currentFieldId,
|
||||
this.isDisabled = false,
|
||||
this.isOptional = false,
|
||||
this.fields = const [],
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? currentFieldId;
|
||||
final bool isDisabled;
|
||||
final bool isOptional;
|
||||
final List<FieldPB> fields;
|
||||
final void Function(String? id)? onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
final selectedField = fields.firstWhereOrNull(
|
||||
(field) => field.id == currentFieldId,
|
||||
);
|
||||
|
||||
return Row(
|
||||
spacing: theme.spacing.s,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AFDropDownMenu<_FieldPBWrapper>(
|
||||
isDisabled: isDisabled,
|
||||
items: fields.map((field) => _FieldPBWrapper(field)).toList(),
|
||||
selectedItems: [
|
||||
if (selectedField != null) _FieldPBWrapper(selectedField),
|
||||
],
|
||||
clearIcon: selectedField == null ||
|
||||
!fields.contains(selectedField) ||
|
||||
!isOptional
|
||||
? null
|
||||
: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
onSelect?.call(null);
|
||||
},
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_clear_m,
|
||||
size: Size.square(16),
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
onSelected: (value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
onSelect?.call(value.field.id);
|
||||
},
|
||||
dropdownIcon: FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
color: theme.iconColorScheme.primary,
|
||||
size: Size(12, 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
@@ -34,96 +46,127 @@ class AiPromptModal extends StatelessWidget {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return AFModal(
|
||||
backgroundColor: theme.backgroundColorScheme.primary,
|
||||
constraints: const BoxConstraints(
|
||||
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.databaseConfig == 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,61 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
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 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'ai_prompt_database_modal.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,
|
||||
),
|
||||
AFFilledButton.primary(
|
||||
onTap: () async {
|
||||
final config = await changeCustomPromptDatabaseConfig(context);
|
||||
|
||||
if (config != null && context.mounted) {
|
||||
context
|
||||
.read<AiPromptSelectorCubit>()
|
||||
.updateCustomPromptDatabaseConfiguration(config);
|
||||
}
|
||||
},
|
||||
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(
|
||||
@@ -72,7 +70,7 @@ class AiPromptPreview extends StatelessWidget {
|
||||
if (prompt.example.isNotEmpty) ...[
|
||||
SelectionContainer.disabled(
|
||||
child: Text(
|
||||
LocaleKeys.ai_customPrompt_example.tr(),
|
||||
LocaleKeys.ai_customPrompt_promptExample.tr(),
|
||||
style: theme.textStyle.heading4.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
@@ -108,7 +106,7 @@ class _PromptContent extends StatelessWidget {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(theme.spacing.l),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.fillColorScheme.quaternary,
|
||||
color: theme.surfaceContainerColorScheme.layer01,
|
||||
borderRadius: BorderRadius.circular(theme.borderRadius.m),
|
||||
),
|
||||
child: Text.rich(
|
||||
@@ -132,7 +130,7 @@ class _PromptContent extends StatelessWidget {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: part,
|
||||
style: TextStyle(color: theme.textColorScheme.purple),
|
||||
style: TextStyle(color: theme.textColorScheme.featured),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -178,7 +176,7 @@ class _PromptExample extends StatelessWidget {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(theme.spacing.l),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.fillColorScheme.quaternary,
|
||||
color: theme.surfaceContainerColorScheme.layer01,
|
||||
borderRadius: BorderRadius.circular(theme.borderRadius.m),
|
||||
),
|
||||
child: AIMarkdownText(
|
||||
|
||||
+225
-51
@@ -4,13 +4,16 @@ 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/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.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_modal.dart';
|
||||
|
||||
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
|
||||
|
||||
class AiPromptVisibleList extends StatefulWidget {
|
||||
@@ -35,12 +38,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,56 +56,149 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spacing = AppFlowyTheme.of(context).spacing;
|
||||
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
listener: (context, state) {
|
||||
final list = state.maybeMap(
|
||||
ready: (state) => state.visiblePrompts,
|
||||
orElse: () => <AiPrompt>[],
|
||||
);
|
||||
handleVisiblePromptListChanged(list);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
VSpace(spacing.l),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: spacing.l),
|
||||
child: buildSearchField(context),
|
||||
),
|
||||
VSpace(spacing.s),
|
||||
Expanded(
|
||||
child: AnimatedList(
|
||||
controller: scrollController,
|
||||
padding: EdgeInsets.all(spacing.l),
|
||||
key: listKey,
|
||||
initialItemCount: oldList.length,
|
||||
itemBuilder: (context, index, animation) {
|
||||
final cubit = context.read<AiPromptSelectorCubit>();
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
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,
|
||||
return Column(
|
||||
children: [
|
||||
BlocConsumer<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
listener: (context, state) {
|
||||
state.maybeMap(
|
||||
ready: (state) {
|
||||
handleVisiblePromptListChanged(state.visiblePrompts);
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
buildWhen: (p, c) {
|
||||
return p.maybeMap(
|
||||
ready: (pr) => c.maybeMap(
|
||||
ready: (cr) =>
|
||||
pr.databaseConfig?.view.id != cr.databaseConfig?.view.id ||
|
||||
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 Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: theme.spacing.l,
|
||||
right: theme.spacing.l,
|
||||
bottom: theme.spacing.l,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(theme.borderRadius.m),
|
||||
color: theme.surfaceContainerColorScheme.layer01,
|
||||
),
|
||||
padding: EdgeInsets.all(theme.spacing.m),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text:
|
||||
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: ",
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
TextSpan(
|
||||
text: readyState
|
||||
.databaseConfig?.view.nameOrDefault ??
|
||||
"",
|
||||
),
|
||||
],
|
||||
),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
child: _AiPromptListItem(
|
||||
animation: animation,
|
||||
prompt: prompt,
|
||||
isSelected: state.selectedPromptId == prompt.id,
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 150,
|
||||
),
|
||||
child: AFOutlinedButton.normal(
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Row(
|
||||
spacing: theme.spacing.s,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (readyState.isLoadingCustomPrompts)
|
||||
buildLoadingIndicator(theme),
|
||||
Flexible(
|
||||
child: Text(
|
||||
readyState.isLoadingCustomPrompts
|
||||
? LocaleKeys.ai_customPrompt_loading
|
||||
.tr()
|
||||
: LocaleKeys.button_change.tr(),
|
||||
maxLines: 1,
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
onTap: () async {
|
||||
final newConfig =
|
||||
await changeCustomPromptDatabaseConfig(
|
||||
context,
|
||||
config: readyState.databaseConfig,
|
||||
);
|
||||
if (newConfig != null && context.mounted) {
|
||||
context
|
||||
.read<AiPromptSelectorCubit>()
|
||||
.updateCustomPromptDatabaseConfiguration(
|
||||
newConfig,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
|
||||
child: buildSearchField(context),
|
||||
),
|
||||
Expanded(
|
||||
child: TextFieldTapRegion(
|
||||
groupId: "ai_prompt_category_list",
|
||||
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
|
||||
builder: (context, state) {
|
||||
return state.maybeMap(
|
||||
ready: (readyState) {
|
||||
if (readyState.visiblePrompts.isEmpty) {
|
||||
return buildEmptyPrompts();
|
||||
}
|
||||
return buildPromptList();
|
||||
},
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,10 +226,13 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
|
||||
.read<AiPromptSelectorCubit>()
|
||||
.filterTextController
|
||||
.clear(),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.toast_close_s,
|
||||
color: theme.textColorScheme.secondary,
|
||||
size: const Size.square(20),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_clear_m,
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
size: const Size.square(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -141,6 +240,81 @@ 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void handleVisiblePromptListChanged(
|
||||
List<AiPrompt> newList,
|
||||
) {
|
||||
@@ -258,16 +432,16 @@ 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
|
||||
? theme.borderColorScheme.themeThickHover
|
||||
: theme.borderColorScheme.themeThick
|
||||
: isHovering
|
||||
? theme.borderColorScheme.greyTertiaryHover
|
||||
: theme.borderColorScheme.greyTertiary,
|
||||
? theme.borderColorScheme.primaryHover
|
||||
: theme.borderColorScheme.primary,
|
||||
),
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_user_cubit.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
@@ -14,6 +15,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'browse_prompts_button.dart';
|
||||
|
||||
typedef OnPromptInputSubmitted = void Function(
|
||||
String input,
|
||||
PredefinedFormat? predefinedFormat,
|
||||
Map<String, dynamic> metadata,
|
||||
String? promptId,
|
||||
);
|
||||
|
||||
class DesktopPromptInput extends StatefulWidget {
|
||||
const DesktopPromptInput({
|
||||
super.key,
|
||||
@@ -31,8 +39,7 @@ class DesktopPromptInput extends StatefulWidget {
|
||||
final bool isStreaming;
|
||||
final AiPromptInputTextEditingController textController;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
|
||||
onSubmitted;
|
||||
final OnPromptInputSubmitted onSubmitted;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
final bool hideDecoration;
|
||||
@@ -57,7 +64,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
chatUserCubit.fetchUserProfile();
|
||||
|
||||
widget.textController.addListener(handleTextControllerChanged);
|
||||
focusNode
|
||||
@@ -77,6 +83,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
checkForAskingAI();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,6 +235,28 @@ 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 promptBloc = context.read<AIPromptInputBloc?>();
|
||||
final promptId = promptBloc?.promptId;
|
||||
final promptState = promptBloc?.state;
|
||||
final predefinedFormat = promptState?.predefinedFormat;
|
||||
if (sources.isNotEmpty) {
|
||||
widget.onUpdateSelectedSources(sources);
|
||||
}
|
||||
widget.onSubmitted.call(query, predefinedFormat, metadata, promptId ?? '');
|
||||
}
|
||||
|
||||
void startMentionPageFromButton() {
|
||||
if (overlayController.isShowing) {
|
||||
return;
|
||||
@@ -287,6 +316,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
userInput,
|
||||
showPredefinedFormats ? predefinedFormat : null,
|
||||
metadata,
|
||||
bloc.promptId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,9 +535,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
|
||||
void handleOnSelectPrompt(AiPrompt prompt) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
bloc.add(
|
||||
AIPromptInputEvent.updateMentionedViews([]),
|
||||
);
|
||||
bloc
|
||||
..add(AIPromptInputEvent.updateMentionedViews([]))
|
||||
..add(AIPromptInputEvent.updatePromptId(prompt.id));
|
||||
|
||||
final content = AiPromptInputTextEditingController.replace(prompt.content);
|
||||
|
||||
|
||||
+2
-2
@@ -56,9 +56,9 @@ class AiPromptInputTextEditingController extends TextEditingController {
|
||||
TextSpan(
|
||||
text: restore(string),
|
||||
style: theme.textStyle.body.standard().copyWith(
|
||||
color: theme.textColorScheme.purple,
|
||||
color: theme.textColorScheme.featured,
|
||||
backgroundColor:
|
||||
theme.fillColorScheme.purpleThick.withAlpha(51),
|
||||
theme.fillColorScheme.featuredThick.withAlpha(51),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+40
-38
@@ -1,13 +1,17 @@
|
||||
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: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';
|
||||
@@ -32,7 +36,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() {
|
||||
@@ -52,6 +67,8 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
builder: (context, state) {
|
||||
final userProfile = context.read<UserWorkspaceBloc>().state.userProfile;
|
||||
@@ -90,12 +107,15 @@ 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,
|
||||
backgroundColor: theme.surfaceColorScheme.primary,
|
||||
maxChildSize: 0.98,
|
||||
enableDraggableScrollable: true,
|
||||
scrollableWidgetBuilder: (_, scrollController) {
|
||||
@@ -129,39 +149,24 @@ 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(
|
||||
pinned: true,
|
||||
delegate: _Header(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).cardColor,
|
||||
color: AppFlowyTheme.of(context).surfaceColorScheme.primary,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -183,10 +188,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 +200,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,29 @@ 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 theme = AppFlowyTheme.of(context);
|
||||
|
||||
final isSaveButtonVisible =
|
||||
showSaveButton && !chatSource.view.isSpace;
|
||||
showSaveButton && !viewSelectorItem.view.isSpace;
|
||||
final isAddButtonVisible = onAdd != null;
|
||||
return Row(
|
||||
children: [
|
||||
@@ -443,29 +429,30 @@ 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,
|
||||
child: Text(
|
||||
viewSelectorItem.view.nameOrDefault,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onHover && (isSaveButtonVisible || isAddButtonVisible)) ...[
|
||||
@@ -479,7 +466,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 +479,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 +494,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 +526,8 @@ class ToggleIsExpandedButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onTap: () => context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleIsExpanded(chatSource, isSelectedSection),
|
||||
.read<ViewSelectorCubit>()
|
||||
.toggleIsExpanded(viewSelectorItem, isSelectedSection),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -565,20 +555,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export 'share_access_level.dart';
|
||||
export 'share_popover_group_id.dart';
|
||||
export 'share_role.dart';
|
||||
export 'shared_user.dart';
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
/// The access level a user can have on a shared page.
|
||||
enum ShareAccessLevel {
|
||||
/// Can view the page only.
|
||||
readOnly,
|
||||
|
||||
/// Can read and comment on the page.
|
||||
readAndComment,
|
||||
|
||||
/// Can read and write to the page.
|
||||
readAndWrite,
|
||||
|
||||
/// Full access (edit, share, remove, etc.) and can add new users.
|
||||
fullAccess;
|
||||
|
||||
String get i18n {
|
||||
switch (this) {
|
||||
case ShareAccessLevel.readOnly:
|
||||
return LocaleKeys.shareTab_accessLevel_view.tr();
|
||||
case ShareAccessLevel.readAndComment:
|
||||
return LocaleKeys.shareTab_accessLevel_comment.tr();
|
||||
case ShareAccessLevel.readAndWrite:
|
||||
return LocaleKeys.shareTab_accessLevel_edit.tr();
|
||||
case ShareAccessLevel.fullAccess:
|
||||
return LocaleKeys.shareTab_accessLevel_fullAccess.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
class SharePopoverGroupId {}
|
||||
@@ -0,0 +1,10 @@
|
||||
enum ShareRole {
|
||||
/// The owner of the shared page.
|
||||
owner,
|
||||
|
||||
/// A guest on the shared page.
|
||||
guest,
|
||||
|
||||
/// A member of the shared page.
|
||||
member,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
|
||||
import 'package:appflowy/features/share_tab/data/models/share_role.dart';
|
||||
|
||||
typedef SharedUsers = List<SharedUser>;
|
||||
|
||||
/// Represents a user with a role on a shared page.
|
||||
class SharedUser {
|
||||
SharedUser({
|
||||
required this.email,
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.accessLevel,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
final String email;
|
||||
|
||||
/// The name of the user.
|
||||
final String name;
|
||||
|
||||
/// The role of the user.
|
||||
final ShareRole role;
|
||||
|
||||
/// The access level of the user.
|
||||
final ShareAccessLevel accessLevel;
|
||||
|
||||
/// The avatar URL of the user.
|
||||
///
|
||||
/// if the avatar is not set, it will be the first letter of the name.
|
||||
final String? avatarUrl;
|
||||
|
||||
SharedUser copyWith({
|
||||
String? email,
|
||||
String? name,
|
||||
ShareRole? role,
|
||||
ShareAccessLevel? accessLevel,
|
||||
String? avatarUrl,
|
||||
}) {
|
||||
return SharedUser(
|
||||
email: email ?? this.email,
|
||||
name: name ?? this.name,
|
||||
role: role ?? this.role,
|
||||
accessLevel: accessLevel ?? this.accessLevel,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
import 'share_with_user_repository.dart';
|
||||
|
||||
class LocalShareWithUserRepository extends ShareWithUserRepository {
|
||||
LocalShareWithUserRepository();
|
||||
|
||||
final SharedUsers _sharedUsers = [
|
||||
SharedUser(
|
||||
email: 'lucas.xu@appflowy.io',
|
||||
name: 'Lucas Xu',
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
role: ShareRole.guest,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'vivian@appflowy.io',
|
||||
name: 'Vivian Wang',
|
||||
accessLevel: ShareAccessLevel.readAndWrite,
|
||||
role: ShareRole.member,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/girl',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'shuheng@appflowy.io',
|
||||
name: 'Shuheng',
|
||||
accessLevel: ShareAccessLevel.fullAccess,
|
||||
role: ShareRole.owner,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'guest_user_1@appflowy.io',
|
||||
name: 'Guest User 1',
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
role: ShareRole.guest,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy/10',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'guest_user_2@appflowy.io',
|
||||
name: 'Guest User 2',
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
role: ShareRole.owner,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy/11',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'member_user_1@appflowy.io',
|
||||
name: 'Member User 1',
|
||||
accessLevel: ShareAccessLevel.readAndWrite,
|
||||
role: ShareRole.member,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy/12',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'member_user_2@appflowy.io',
|
||||
name: 'Member User 2',
|
||||
accessLevel: ShareAccessLevel.readAndWrite,
|
||||
role: ShareRole.member,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy/13',
|
||||
),
|
||||
];
|
||||
|
||||
final SharedUsers _availableSharedUsers = [
|
||||
SharedUser(
|
||||
email: 'guest_email@appflowy.io',
|
||||
name: 'Guest',
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
role: ShareRole.guest,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy/28',
|
||||
),
|
||||
SharedUser(
|
||||
email: 'richard@appflowy.io',
|
||||
name: 'Richard',
|
||||
accessLevel: ShareAccessLevel.readAndWrite,
|
||||
role: ShareRole.member,
|
||||
avatarUrl: 'https://avatar.iran.liara.run/public/boy/28',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
|
||||
required String pageId,
|
||||
}) async {
|
||||
return FlowySuccess(_sharedUsers);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
|
||||
required String pageId,
|
||||
required List<String> emails,
|
||||
}) async {
|
||||
for (final email in emails) {
|
||||
_sharedUsers.removeWhere((user) => user.email == email);
|
||||
}
|
||||
|
||||
return FlowySuccess(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
|
||||
required String pageId,
|
||||
required ShareAccessLevel accessLevel,
|
||||
required List<String> emails,
|
||||
}) async {
|
||||
for (final email in emails) {
|
||||
final index = _sharedUsers.indexWhere((user) => user.email == email);
|
||||
if (index != -1) {
|
||||
// Update access level if user exists
|
||||
final user = _sharedUsers[index];
|
||||
_sharedUsers[index] = SharedUser(
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
accessLevel: accessLevel,
|
||||
role: user.role,
|
||||
avatarUrl: user.avatarUrl,
|
||||
);
|
||||
} else {
|
||||
// Add new user
|
||||
_sharedUsers.add(
|
||||
SharedUser(
|
||||
name: email.split('@').first,
|
||||
email: email,
|
||||
accessLevel: accessLevel,
|
||||
role: ShareRole.guest,
|
||||
avatarUrl:
|
||||
'https://avatar.iran.liara.run/public/${Random().nextInt(100)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return FlowySuccess(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
|
||||
required String pageId,
|
||||
}) async {
|
||||
return FlowySuccess([
|
||||
..._sharedUsers,
|
||||
..._availableSharedUsers,
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> changeRole({
|
||||
required String pageId,
|
||||
required String email,
|
||||
required ShareRole role,
|
||||
}) async {
|
||||
final index = _sharedUsers.indexWhere((user) => user.email == email);
|
||||
if (index != -1) {
|
||||
_sharedUsers[index] = _sharedUsers[index].copyWith(role: role);
|
||||
}
|
||||
|
||||
return FlowySuccess(null);
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/features/util/extensions.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
import 'share_with_user_repository.dart';
|
||||
|
||||
class RustShareWithUserRepository extends ShareWithUserRepository {
|
||||
RustShareWithUserRepository();
|
||||
|
||||
@override
|
||||
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
|
||||
required String pageId,
|
||||
}) async {
|
||||
final request = GetSharedUsersPayloadPB(
|
||||
viewId: pageId,
|
||||
);
|
||||
final result = await FolderEventGetSharedUsers(request).send();
|
||||
|
||||
return result.fold(
|
||||
(success) {
|
||||
Log.info('get shared users: $success');
|
||||
|
||||
return FlowySuccess(success.sharedUsers);
|
||||
},
|
||||
(failure) {
|
||||
Log.error('getUsersInSharedPage: $failure');
|
||||
|
||||
return FlowyFailure(failure);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
|
||||
required String pageId,
|
||||
required List<String> emails,
|
||||
}) async {
|
||||
final request = RemoveUserFromSharedPagePayloadPB(
|
||||
viewId: pageId,
|
||||
emails: emails,
|
||||
);
|
||||
final result = await FolderEventRemoveUserFromSharedPage(request).send();
|
||||
|
||||
return result.fold(
|
||||
(success) {
|
||||
Log.info('remove users($emails) from shared page($pageId)');
|
||||
|
||||
return FlowySuccess(success);
|
||||
},
|
||||
(failure) {
|
||||
Log.error('removeUserFromPage: $failure');
|
||||
|
||||
return FlowyFailure(failure);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
|
||||
required String pageId,
|
||||
required ShareAccessLevel accessLevel,
|
||||
required List<String> emails,
|
||||
}) async {
|
||||
final request = SharePageWithUserPayloadPB(
|
||||
viewId: pageId,
|
||||
emails: emails,
|
||||
accessLevel: accessLevel.accessLevel,
|
||||
);
|
||||
final result = await FolderEventSharePageWithUser(request).send();
|
||||
|
||||
return result.fold(
|
||||
(success) {
|
||||
Log.info(
|
||||
'share page($pageId) with users($emails) with access level($accessLevel)',
|
||||
);
|
||||
|
||||
return FlowySuccess(success);
|
||||
},
|
||||
(failure) {
|
||||
Log.error('sharePageWithUser: $failure');
|
||||
|
||||
return FlowyFailure(failure);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
|
||||
required String pageId,
|
||||
}) async {
|
||||
// TODO: Implement this
|
||||
return FlowySuccess([]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<void, FlowyError>> changeRole({
|
||||
required String pageId,
|
||||
required String email,
|
||||
required ShareRole role,
|
||||
}) async {
|
||||
final request = UpdateWorkspaceMemberPB(
|
||||
workspaceId: pageId,
|
||||
email: email,
|
||||
role: role.userRole,
|
||||
);
|
||||
final result = await UserEventUpdateWorkspaceMember(request).send();
|
||||
return result.fold(
|
||||
(success) {
|
||||
Log.info('change role($role) for user($email) in page($pageId)');
|
||||
return FlowySuccess(success);
|
||||
},
|
||||
(failure) {
|
||||
Log.error(
|
||||
'failed to change role($role) for user($email) in page($pageId)',
|
||||
failure,
|
||||
);
|
||||
return FlowyFailure(failure);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
/// Abstract repository for sharing with users.
|
||||
///
|
||||
/// For example, we're using rust events now, but we can still use the http api
|
||||
/// for the future.
|
||||
abstract class ShareWithUserRepository {
|
||||
/// Gets the list of users and their roles for a shared page.
|
||||
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
|
||||
required String pageId,
|
||||
});
|
||||
|
||||
/// Gets the list of users that are available to be shared with.
|
||||
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
|
||||
required String pageId,
|
||||
});
|
||||
|
||||
/// Removes a user from a shared page.
|
||||
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
|
||||
required String pageId,
|
||||
required List<String> emails,
|
||||
});
|
||||
|
||||
/// Shares a page with a user, assigning a role.
|
||||
///
|
||||
/// If the user is already in the shared page, the access level will be updated.
|
||||
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
|
||||
required String pageId,
|
||||
required ShareAccessLevel accessLevel,
|
||||
required List<String> emails,
|
||||
});
|
||||
|
||||
/// Change the role of a user in a shared page.
|
||||
Future<FlowyResult<void, FlowyError>> changeRole({
|
||||
required String pageId,
|
||||
required String email,
|
||||
required ShareRole role,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'share_with_user_bloc.freezed.dart';
|
||||
|
||||
class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
|
||||
ShareWithUserBloc({
|
||||
required this.repository,
|
||||
required this.pageId,
|
||||
required this.workspaceId,
|
||||
}) : super(ShareWithUserState.initial()) {
|
||||
on<Initial>(_onInitial);
|
||||
on<GetSharedUsers>(_onGetSharedUsers);
|
||||
on<ShareWithUser>(_onShare);
|
||||
on<RemoveUser>(_onRemove);
|
||||
on<UpdateAccessLevel>(_onUpdateAccessLevel);
|
||||
on<UpdateGeneralAccess>(_onUpdateGeneralAccess);
|
||||
on<CopyLink>(_onCopyLink);
|
||||
on<SearchAvailableUsers>(_onSearchAvailableUsers);
|
||||
on<TurnIntoMember>(_onTurnIntoMember);
|
||||
}
|
||||
|
||||
final ShareWithUserRepository repository;
|
||||
final String workspaceId;
|
||||
final String pageId;
|
||||
|
||||
Future<void> _onInitial(
|
||||
Initial event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
if (!FeatureFlag.sharedSection.isOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await UserBackendService.getCurrentUserProfile();
|
||||
final currentUser = result.fold(
|
||||
(user) => user,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
final shareLink = ShareConstants.buildShareUrl(
|
||||
workspaceId: workspaceId,
|
||||
viewId: pageId,
|
||||
);
|
||||
|
||||
final users = await _getLatestSharedUsersOrCurrentUsers();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentUser: currentUser,
|
||||
shareLink: shareLink,
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onGetSharedUsers(
|
||||
GetSharedUsers event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
if (!FeatureFlag.sharedSection.isOn) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: '',
|
||||
initialResult: null,
|
||||
shareResult: null,
|
||||
removeResult: null,
|
||||
updateAccessLevelResult: null,
|
||||
turnIntoMemberResult: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.getSharedUsersInPage(
|
||||
pageId: pageId,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(users) => emit(
|
||||
state.copyWith(
|
||||
users: users,
|
||||
initialResult: FlowySuccess(null),
|
||||
),
|
||||
),
|
||||
(error) => emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
initialResult: FlowyFailure(error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onShare(
|
||||
ShareWithUser event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: '',
|
||||
shareResult: null,
|
||||
turnIntoMemberResult: null,
|
||||
removeResult: null,
|
||||
updateAccessLevelResult: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.sharePageWithUser(
|
||||
pageId: pageId,
|
||||
accessLevel: event.accessLevel,
|
||||
emails: event.emails,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(_) async {
|
||||
final users = await _getLatestSharedUsersOrCurrentUsers();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
shareResult: FlowySuccess(null),
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
shareResult: FlowyFailure(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRemove(
|
||||
RemoveUser event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: '',
|
||||
removeResult: null,
|
||||
shareResult: null,
|
||||
updateAccessLevelResult: null,
|
||||
turnIntoMemberResult: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.removeSharedUserFromPage(
|
||||
pageId: pageId,
|
||||
emails: event.emails,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(_) async {
|
||||
final users = await _getLatestSharedUsersOrCurrentUsers();
|
||||
emit(
|
||||
state.copyWith(
|
||||
removeResult: FlowySuccess(null),
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
removeResult: FlowyFailure(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpdateAccessLevel(
|
||||
UpdateAccessLevel event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
updateAccessLevelResult: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.sharePageWithUser(
|
||||
pageId: pageId,
|
||||
accessLevel: event.accessLevel,
|
||||
emails: [event.email],
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(_) async {
|
||||
final users = await _getLatestSharedUsersOrCurrentUsers();
|
||||
emit(
|
||||
state.copyWith(
|
||||
updateAccessLevelResult: FlowySuccess(null),
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onUpdateGeneralAccess(
|
||||
UpdateGeneralAccess event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
generalAccessRole: event.accessLevel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCopyLink(CopyLink event, Emitter<ShareWithUserState> emit) {
|
||||
getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: event.link,
|
||||
),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
linkCopied: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSearchAvailableUsers(
|
||||
SearchAvailableUsers event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: '',
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.getAvailableSharedUsers(pageId: pageId);
|
||||
|
||||
result.fold(
|
||||
(users) {
|
||||
// filter by email and name
|
||||
final availableUsers = users.where((user) {
|
||||
final query = event.query.toLowerCase();
|
||||
return user.name.toLowerCase().contains(query) ||
|
||||
user.email.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
emit(
|
||||
state.copyWith(
|
||||
availableUsers: availableUsers,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) => emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
availableUsers: [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTurnIntoMember(
|
||||
TurnIntoMember event,
|
||||
Emitter<ShareWithUserState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
turnIntoMemberResult: null,
|
||||
errorMessage: '',
|
||||
removeResult: null,
|
||||
shareResult: null,
|
||||
updateAccessLevelResult: null,
|
||||
),
|
||||
);
|
||||
|
||||
final result = await repository.changeRole(
|
||||
pageId: pageId,
|
||||
email: event.email,
|
||||
role: ShareRole.member,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(_) async {
|
||||
final users = await _getLatestSharedUsersOrCurrentUsers();
|
||||
emit(
|
||||
state.copyWith(
|
||||
turnIntoMemberResult: FlowySuccess(null),
|
||||
users: users,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
turnIntoMemberResult: FlowyFailure(error),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<SharedUsers> _getLatestSharedUsersOrCurrentUsers() async {
|
||||
final shareResult = await repository.getSharedUsersInPage(
|
||||
pageId: pageId,
|
||||
);
|
||||
return shareResult.fold(
|
||||
(users) => users,
|
||||
(error) => state.users,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ShareWithUserEvent with _$ShareWithUserEvent {
|
||||
/// Initializes the bloc.
|
||||
const factory ShareWithUserEvent.init() = Initial;
|
||||
|
||||
/// Loads the shared users for the page.
|
||||
const factory ShareWithUserEvent.getSharedUsers() = GetSharedUsers;
|
||||
|
||||
/// Invites the users to the page.
|
||||
const factory ShareWithUserEvent.share({
|
||||
required List<String> emails,
|
||||
required ShareAccessLevel accessLevel,
|
||||
}) = ShareWithUser;
|
||||
|
||||
/// Removes the users from the page.
|
||||
const factory ShareWithUserEvent.remove({
|
||||
required List<String> emails,
|
||||
}) = RemoveUser;
|
||||
|
||||
/// Updates the access level of the user.
|
||||
const factory ShareWithUserEvent.updateAccessLevel({
|
||||
required String email,
|
||||
required ShareAccessLevel accessLevel,
|
||||
}) = UpdateAccessLevel;
|
||||
|
||||
/// Updates the general access role for all users.
|
||||
const factory ShareWithUserEvent.updateGeneralAccess({
|
||||
required ShareAccessLevel accessLevel,
|
||||
}) = UpdateGeneralAccess;
|
||||
|
||||
/// Copies the link to the clipboard.
|
||||
const factory ShareWithUserEvent.copyLink({
|
||||
required String link,
|
||||
}) = CopyLink;
|
||||
|
||||
/// Searches available users by name or email.
|
||||
const factory ShareWithUserEvent.searchAvailableUsers({
|
||||
required String query,
|
||||
}) = SearchAvailableUsers;
|
||||
|
||||
/// Turns the user into a member.
|
||||
const factory ShareWithUserEvent.turnIntoMember({
|
||||
required String email,
|
||||
}) = TurnIntoMember;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ShareWithUserState with _$ShareWithUserState {
|
||||
const factory ShareWithUserState({
|
||||
@Default(null) UserProfilePB? currentUser,
|
||||
@Default([]) SharedUsers users,
|
||||
@Default([]) SharedUsers availableUsers,
|
||||
@Default(false) bool isLoading,
|
||||
@Default('') String errorMessage,
|
||||
@Default('') String shareLink,
|
||||
ShareAccessLevel? generalAccessRole,
|
||||
@Default(false) bool linkCopied,
|
||||
@Default(null) FlowyResult<void, FlowyError>? initialResult,
|
||||
@Default(null) FlowyResult<void, FlowyError>? shareResult,
|
||||
@Default(null) FlowyResult<void, FlowyError>? removeResult,
|
||||
@Default(null) FlowyResult<void, FlowyError>? updateAccessLevelResult,
|
||||
@Default(null) FlowyResult<void, FlowyError>? turnIntoMemberResult,
|
||||
}) = _ShareWithUserState;
|
||||
|
||||
factory ShareWithUserState.initial() => const ShareWithUserState();
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
|
||||
import 'package:appflowy/features/share_tab/logic/share_with_user_bloc.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widget.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ShareTab extends StatefulWidget {
|
||||
const ShareTab({
|
||||
super.key,
|
||||
required this.workspaceId,
|
||||
required this.pageId,
|
||||
});
|
||||
|
||||
final String workspaceId;
|
||||
final String pageId;
|
||||
|
||||
@override
|
||||
State<ShareTab> createState() => _ShareTabState();
|
||||
}
|
||||
|
||||
class _ShareTabState extends State<ShareTab> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return BlocConsumer<ShareWithUserBloc, ShareWithUserState>(
|
||||
listener: (context, state) {
|
||||
_onListenShareWithUserState(context, state);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// share page with user by email
|
||||
VSpace(theme.spacing.l),
|
||||
ShareWithUserWidget(
|
||||
controller: controller,
|
||||
onInvite: (emails) => _onSharePageWithUser(
|
||||
context,
|
||||
emails: emails,
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
),
|
||||
),
|
||||
|
||||
// shared users
|
||||
|
||||
if (state.users.isNotEmpty) ...[
|
||||
VSpace(theme.spacing.l),
|
||||
PeopleWithAccessSection(
|
||||
currentUserEmail: state.currentUser?.email ?? '',
|
||||
users: state.users,
|
||||
callbacks: _buildPeopleWithAccessSectionCallbacks(context),
|
||||
),
|
||||
],
|
||||
|
||||
// general access
|
||||
// enable it when the backend support general access features.
|
||||
// VSpace(theme.spacing.m),
|
||||
// GeneralAccessSection(),
|
||||
|
||||
// copy link
|
||||
VSpace(theme.spacing.l),
|
||||
const AFDivider(),
|
||||
VSpace(theme.spacing.xl),
|
||||
CopyLinkWidget(shareLink: state.shareLink),
|
||||
VSpace(theme.spacing.m),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSharePageWithUser(
|
||||
BuildContext context, {
|
||||
required List<String> emails,
|
||||
required ShareAccessLevel accessLevel,
|
||||
}) {
|
||||
context.read<ShareWithUserBloc>().add(
|
||||
ShareWithUserEvent.share(emails: emails, accessLevel: accessLevel),
|
||||
);
|
||||
}
|
||||
|
||||
PeopleWithAccessSectionCallbacks _buildPeopleWithAccessSectionCallbacks(
|
||||
BuildContext context,
|
||||
) {
|
||||
return PeopleWithAccessSectionCallbacks(
|
||||
onSelectAccessLevel: (user, accessLevel) {
|
||||
// do nothing. the event doesn't support in the backend yet
|
||||
},
|
||||
onTurnIntoMember: (user) {
|
||||
context.read<ShareWithUserBloc>().add(
|
||||
ShareWithUserEvent.turnIntoMember(email: user.email),
|
||||
);
|
||||
},
|
||||
onRemoveAccess: (user) {
|
||||
context.read<ShareWithUserBloc>().add(
|
||||
ShareWithUserEvent.remove(emails: [user.email]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onListenShareWithUserState(
|
||||
BuildContext context,
|
||||
ShareWithUserState state,
|
||||
) {
|
||||
final shareResult = state.shareResult;
|
||||
if (shareResult != null) {
|
||||
shareResult.fold((success) {
|
||||
// clear the controller to avoid showing the previous emails
|
||||
controller.clear();
|
||||
|
||||
showToastNotification(
|
||||
message: 'Invitation sent',
|
||||
);
|
||||
}, (error) {
|
||||
// TODO: handle the limiation error
|
||||
showToastNotification(
|
||||
message: error.msg,
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final removeResult = state.removeResult;
|
||||
if (removeResult != null) {
|
||||
removeResult.fold((success) {
|
||||
showToastNotification(
|
||||
message: 'Removed guest successfully',
|
||||
);
|
||||
}, (error) {
|
||||
showToastNotification(
|
||||
message: error.msg,
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
final updateAccessLevelResult = state.updateAccessLevelResult;
|
||||
if (updateAccessLevelResult != null) {
|
||||
updateAccessLevelResult.fold((success) {
|
||||
showToastNotification(
|
||||
message: 'Updated access level successfully',
|
||||
);
|
||||
}, (error) {
|
||||
showToastNotification(
|
||||
message: error.msg,
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AccessLevelListCallbacks {
|
||||
const AccessLevelListCallbacks({
|
||||
required this.onSelectAccessLevel,
|
||||
required this.onTurnIntoMember,
|
||||
required this.onRemoveAccess,
|
||||
});
|
||||
|
||||
factory AccessLevelListCallbacks.none() {
|
||||
return AccessLevelListCallbacks(
|
||||
onSelectAccessLevel: (_) {},
|
||||
onTurnIntoMember: () {},
|
||||
onRemoveAccess: () {},
|
||||
);
|
||||
}
|
||||
|
||||
/// Callback when an access level is selected
|
||||
final void Function(ShareAccessLevel accessLevel) onSelectAccessLevel;
|
||||
|
||||
/// Callback when the "Turn into Member" option is selected
|
||||
final VoidCallback onTurnIntoMember;
|
||||
|
||||
/// Callback when the "Remove access" option is selected
|
||||
final VoidCallback onRemoveAccess;
|
||||
|
||||
/// Copy
|
||||
AccessLevelListCallbacks copyWith({
|
||||
VoidCallback? onRemoveAccess,
|
||||
VoidCallback? onTurnIntoMember,
|
||||
void Function(ShareAccessLevel accessLevel)? onSelectAccessLevel,
|
||||
}) {
|
||||
return AccessLevelListCallbacks(
|
||||
onRemoveAccess: onRemoveAccess ?? this.onRemoveAccess,
|
||||
onTurnIntoMember: onTurnIntoMember ?? this.onTurnIntoMember,
|
||||
onSelectAccessLevel: onSelectAccessLevel ?? this.onSelectAccessLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that displays a list of access levels for sharing.
|
||||
///
|
||||
/// This widget is used in a popover to allow users to select different access levels
|
||||
/// for shared content, as well as options to turn users into members or remove access.
|
||||
class AccessLevelListWidget extends StatelessWidget {
|
||||
const AccessLevelListWidget({
|
||||
super.key,
|
||||
this.enableAccessLevelSelection = false,
|
||||
required this.selectedAccessLevel,
|
||||
required this.callbacks,
|
||||
});
|
||||
|
||||
/// Enable access level selection
|
||||
final bool enableAccessLevelSelection;
|
||||
|
||||
/// The currently selected access level
|
||||
final ShareAccessLevel selectedAccessLevel;
|
||||
|
||||
/// Callbacks
|
||||
final AccessLevelListCallbacks callbacks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return AFMenu(
|
||||
width: enableAccessLevelSelection ? 240 : 160,
|
||||
children: [
|
||||
// Display all available access level options
|
||||
if (enableAccessLevelSelection) ...[
|
||||
_buildAccessLevelItem(
|
||||
context,
|
||||
accessLevel: ShareAccessLevel.fullAccess,
|
||||
onTap: () =>
|
||||
callbacks.onSelectAccessLevel(ShareAccessLevel.fullAccess),
|
||||
),
|
||||
_buildAccessLevelItem(
|
||||
context,
|
||||
accessLevel: ShareAccessLevel.readAndWrite,
|
||||
onTap: () =>
|
||||
callbacks.onSelectAccessLevel(ShareAccessLevel.readAndWrite),
|
||||
),
|
||||
_buildAccessLevelItem(
|
||||
context,
|
||||
accessLevel: ShareAccessLevel.readAndComment,
|
||||
onTap: () =>
|
||||
callbacks.onSelectAccessLevel(ShareAccessLevel.readAndComment),
|
||||
),
|
||||
_buildAccessLevelItem(
|
||||
context,
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
onTap: () =>
|
||||
callbacks.onSelectAccessLevel(ShareAccessLevel.readOnly),
|
||||
),
|
||||
AFDivider(spacing: theme.spacing.m),
|
||||
],
|
||||
|
||||
// Additional user management options
|
||||
AFTextMenuItem(
|
||||
title: LocaleKeys.shareTab_turnIntoMember.tr(),
|
||||
onTap: callbacks.onTurnIntoMember,
|
||||
),
|
||||
AFTextMenuItem(
|
||||
title: LocaleKeys.shareTab_removeAccess.tr(),
|
||||
titleColor: theme.textColorScheme.error,
|
||||
onTap: callbacks.onRemoveAccess,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds an individual access level menu item
|
||||
///
|
||||
/// @param context The build context
|
||||
/// @param accessLevel The access level to display
|
||||
/// @param onTap Callback when this item is tapped
|
||||
/// @return A menu item widget for the specified access level
|
||||
Widget _buildAccessLevelItem(
|
||||
BuildContext context, {
|
||||
required ShareAccessLevel accessLevel,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return AFTextMenuItem(
|
||||
title: accessLevel.i18n,
|
||||
showSelectedBackground: false,
|
||||
selected: selectedAccessLevel == accessLevel,
|
||||
// Show a checkmark icon for the currently selected access level
|
||||
trailing: selectedAccessLevel == accessLevel
|
||||
? FlowySvg(
|
||||
FlowySvgs.m_blue_check_s,
|
||||
blendMode: null,
|
||||
)
|
||||
: null,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import 'package:appflowy/features/share_tab/logic/share_with_user_bloc.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.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 CopyLinkWidget extends StatelessWidget {
|
||||
const CopyLinkWidget({
|
||||
super.key,
|
||||
required this.shareLink,
|
||||
});
|
||||
|
||||
final String shareLink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AFTextField(
|
||||
initialText: shareLink,
|
||||
size: AFTextFieldSize.m,
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
HSpace(theme.spacing.s),
|
||||
AFOutlinedIconTextButton.normal(
|
||||
text: LocaleKeys.shareTab_copyLink.tr(),
|
||||
size: AFButtonSize.l,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: theme.spacing.l,
|
||||
vertical: theme.spacing.s,
|
||||
),
|
||||
iconBuilder: (context, isHovering, disabled) => FlowySvg(
|
||||
FlowySvgs.toolbar_link_m,
|
||||
),
|
||||
onTap: () {
|
||||
context.read<ShareWithUserBloc>().add(
|
||||
ShareWithUserEvent.copyLink(link: shareLink),
|
||||
);
|
||||
|
||||
showToastNotification(
|
||||
message: LocaleKeys.shareTab_copiedLinkToClipboard.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EditAccessLevelWidget extends StatefulWidget {
|
||||
const EditAccessLevelWidget({
|
||||
super.key,
|
||||
required this.callbacks,
|
||||
required this.selectedAccessLevel,
|
||||
this.disabled = false,
|
||||
});
|
||||
|
||||
/// Callbacks
|
||||
final AccessLevelListCallbacks callbacks;
|
||||
|
||||
/// The currently selected access level
|
||||
final ShareAccessLevel selectedAccessLevel;
|
||||
|
||||
/// Whether the widget is disabled
|
||||
final bool disabled;
|
||||
|
||||
@override
|
||||
State<EditAccessLevelWidget> createState() => _EditAccessLevelWidgetState();
|
||||
}
|
||||
|
||||
class _EditAccessLevelWidgetState extends State<EditAccessLevelWidget> {
|
||||
final popoverController = AFPopoverController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
popoverController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return AFPopover(
|
||||
padding: EdgeInsets.zero,
|
||||
decoration: BoxDecoration(), // the access level widget has a border
|
||||
controller: popoverController,
|
||||
popover: (_) {
|
||||
return AccessLevelListWidget(
|
||||
selectedAccessLevel: widget.selectedAccessLevel,
|
||||
callbacks: widget.callbacks.copyWith(
|
||||
onSelectAccessLevel: (accessLevel) {
|
||||
widget.callbacks.onSelectAccessLevel(accessLevel);
|
||||
|
||||
popoverController.hide();
|
||||
},
|
||||
onRemoveAccess: () {
|
||||
widget.callbacks.onRemoveAccess();
|
||||
|
||||
popoverController.hide();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
child: AFGhostButton.normal(
|
||||
disabled: widget.disabled,
|
||||
onTap: () {
|
||||
popoverController.show();
|
||||
},
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
horizontal: theme.spacing.l,
|
||||
),
|
||||
builder: (context, isHovering, disabled) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.selectedAccessLevel.i18n,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: disabled
|
||||
? theme.textColorScheme.secondary
|
||||
: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
HSpace(theme.spacing.xs),
|
||||
FlowySvg(
|
||||
FlowySvgs.arrow_down_s,
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/shared_group_widget.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GeneralAccessSection extends StatelessWidget {
|
||||
const GeneralAccessSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return AFMenuSection(
|
||||
title: 'General access',
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
),
|
||||
children: [
|
||||
SharedGroupWidget(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/shared_user_widget.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PeopleWithAccessSectionCallbacks {
|
||||
const PeopleWithAccessSectionCallbacks({
|
||||
required this.onRemoveAccess,
|
||||
required this.onTurnIntoMember,
|
||||
required this.onSelectAccessLevel,
|
||||
});
|
||||
|
||||
factory PeopleWithAccessSectionCallbacks.none() {
|
||||
return PeopleWithAccessSectionCallbacks(
|
||||
onSelectAccessLevel: (_, __) {},
|
||||
onTurnIntoMember: (_) {},
|
||||
onRemoveAccess: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
/// Callback when an access level is selected
|
||||
final void Function(SharedUser user, ShareAccessLevel accessLevel)
|
||||
onSelectAccessLevel;
|
||||
|
||||
/// Callback when the "Turn into Member" option is selected
|
||||
final void Function(SharedUser user) onTurnIntoMember;
|
||||
|
||||
/// Callback when the "Remove access" option is selected
|
||||
final void Function(SharedUser user) onRemoveAccess;
|
||||
}
|
||||
|
||||
class PeopleWithAccessSection extends StatelessWidget {
|
||||
const PeopleWithAccessSection({
|
||||
super.key,
|
||||
required this.currentUserEmail,
|
||||
required this.users,
|
||||
this.callbacks,
|
||||
});
|
||||
|
||||
final String currentUserEmail;
|
||||
final SharedUsers users;
|
||||
final PeopleWithAccessSectionCallbacks? callbacks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final currentUser = users.firstWhereOrNull(
|
||||
(user) => user.email == currentUserEmail,
|
||||
);
|
||||
|
||||
return AFMenuSection(
|
||||
title: 'People with access',
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: 240,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.xs,
|
||||
),
|
||||
children: users.map((user) {
|
||||
if (currentUser == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SharedUserWidget(
|
||||
user: user,
|
||||
currentUser: currentUser,
|
||||
callbacks: AccessLevelListCallbacks(
|
||||
onRemoveAccess: () => callbacks?.onRemoveAccess.call(user),
|
||||
onTurnIntoMember: () => callbacks?.onTurnIntoMember.call(user),
|
||||
onSelectAccessLevel: (accessLevel) =>
|
||||
callbacks?.onSelectAccessLevel.call(user, accessLevel),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
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/material.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
|
||||
class ShareWithUserWidget extends StatefulWidget {
|
||||
const ShareWithUserWidget({
|
||||
super.key,
|
||||
required this.onInvite,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final void Function(List<String> emails) onInvite;
|
||||
final TextEditingController? controller;
|
||||
|
||||
@override
|
||||
State<ShareWithUserWidget> createState() => _ShareWithUserWidgetState();
|
||||
}
|
||||
|
||||
class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
|
||||
late final TextEditingController effectiveController;
|
||||
bool isButtonEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
effectiveController = widget.controller ?? TextEditingController();
|
||||
effectiveController.addListener(_onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.controller == null) {
|
||||
effectiveController.dispose();
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AFTextField(
|
||||
controller: effectiveController,
|
||||
size: AFTextFieldSize.m,
|
||||
hintText: LocaleKeys.shareTab_inviteByEmail.tr(),
|
||||
),
|
||||
),
|
||||
HSpace(theme.spacing.s),
|
||||
AFFilledTextButton.primary(
|
||||
text: LocaleKeys.shareTab_invite.tr(),
|
||||
disabled: !isButtonEnabled,
|
||||
onTap: () {
|
||||
widget.onInvite(effectiveController.text.trim().split(','));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
setState(() {
|
||||
final texts = effectiveController.text.trim().split(',');
|
||||
isButtonEnabled = texts.isNotEmpty && texts.every(isEmail);
|
||||
});
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
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/material.dart';
|
||||
|
||||
class SharedGroupWidget extends StatelessWidget {
|
||||
const SharedGroupWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return AFMenuItem(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
),
|
||||
leading: _buildLeading(context),
|
||||
title: _buildTitle(context),
|
||||
subtitle: _buildSubtitle(context),
|
||||
trailing: _buildTrailing(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLeading(BuildContext context) {
|
||||
return AFAvatar(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.app_logo_s, // replace it with group avatar
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
LocaleKeys.shareTab_anyoneAtWorkspace.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
HSpace(theme.spacing.xs),
|
||||
FlowySvg(
|
||||
FlowySvgs.arrow_down_s,
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubtitle(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Text(
|
||||
LocaleKeys.shareTab_anyoneInGroupWithLinkCanEdit.tr(),
|
||||
textAlign: TextAlign.left,
|
||||
style: theme.textStyle.caption.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrailing(BuildContext context) {
|
||||
return EditAccessLevelWidget(
|
||||
disabled: true,
|
||||
selectedAccessLevel: ShareAccessLevel.fullAccess,
|
||||
callbacks: AccessLevelListCallbacks.none(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
|
||||
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
|
||||
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/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Widget to display a single shared user row as per the UI design, using AFMenuItem.
|
||||
class SharedUserWidget extends StatelessWidget {
|
||||
const SharedUserWidget({
|
||||
super.key,
|
||||
required this.user,
|
||||
required this.currentUser,
|
||||
this.callbacks,
|
||||
});
|
||||
|
||||
final SharedUser user;
|
||||
final SharedUser currentUser;
|
||||
final AccessLevelListCallbacks? callbacks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
return AFMenuItem(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: theme.spacing.s,
|
||||
),
|
||||
leading: AFAvatar(
|
||||
name: user.name,
|
||||
url: user.avatarUrl,
|
||||
),
|
||||
title: _buildTitle(context),
|
||||
subtitle: _buildSubtitle(context),
|
||||
trailing: _buildTrailing(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final isCurrentUser = user.email == currentUser.email;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.name,
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// if the user is the current user, show '(You)'
|
||||
if (isCurrentUser) ...[
|
||||
HSpace(theme.spacing.xs),
|
||||
Text(
|
||||
LocaleKeys.shareTab_you.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
// if the user is a guest, show 'Guest'
|
||||
if (user.role == ShareRole.guest) ...[
|
||||
HSpace(theme.spacing.xs),
|
||||
Text(
|
||||
LocaleKeys.shareTab_guest.tr(),
|
||||
style: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubtitle(
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Text(
|
||||
user.email,
|
||||
style: theme.textStyle.caption.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrailing(
|
||||
BuildContext context,
|
||||
) {
|
||||
final isCurrentUser = user.email == currentUser.email;
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
// The current guest user can't edit the access level of the other user
|
||||
return isCurrentUser || currentUser.role == ShareRole.guest
|
||||
? AFGhostTextButton.disabled(
|
||||
text: user.accessLevel.i18n,
|
||||
textStyle: theme.textStyle.body.standard(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
)
|
||||
: EditAccessLevelWidget(
|
||||
selectedAccessLevel: user.accessLevel,
|
||||
callbacks: callbacks ?? AccessLevelListCallbacks.none(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
|
||||
import 'package:appflowy/features/shared_section/data/share_pages_repository.dart';
|
||||
import 'package:appflowy/features/shared_section/models/shared_page.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
class LocalSharePagesRepository implements SharePagesRepository {
|
||||
@override
|
||||
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages() async {
|
||||
final pages = [
|
||||
SharedPage(
|
||||
view: ViewPB()
|
||||
..id = '1'
|
||||
..name = 'Welcome Page',
|
||||
accessLevel: ShareAccessLevel.fullAccess,
|
||||
),
|
||||
SharedPage(
|
||||
view: ViewPB()
|
||||
..id = '2'
|
||||
..name = 'Project Plan',
|
||||
accessLevel: ShareAccessLevel.readAndWrite,
|
||||
),
|
||||
SharedPage(
|
||||
view: ViewPB()
|
||||
..id = '3'
|
||||
..name = 'Readme',
|
||||
accessLevel: ShareAccessLevel.readOnly,
|
||||
),
|
||||
];
|
||||
return FlowyResult.success(pages);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import 'package:appflowy/features/shared_section/data/share_pages_repository.dart';
|
||||
import 'package:appflowy/features/shared_section/models/shared_page.dart';
|
||||
import 'package:appflowy/features/util/extensions.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
class RustSharePagesRepository implements SharePagesRepository {
|
||||
@override
|
||||
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages() async {
|
||||
final result = await FolderEventGetSharedViews().send();
|
||||
return result.fold(
|
||||
(success) {
|
||||
final sharedPages = success.sharedPages;
|
||||
|
||||
Log.info('get shared pages success, len: ${sharedPages.length}');
|
||||
|
||||
return FlowyResult.success(sharedPages);
|
||||
},
|
||||
(error) {
|
||||
Log.error('failed to get shared pages, error: $error');
|
||||
|
||||
return FlowyResult.failure(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import 'package:appflowy/features/shared_section/models/shared_page.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
/// Abstract repository for sharing pages with users.
|
||||
///
|
||||
/// For example, we're using rust events now, but we can still use the http api
|
||||
/// for the future.
|
||||
abstract class SharePagesRepository {
|
||||
/// Gets the list of users and their roles for a shared page.
|
||||
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages();
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/core/notification/folder_notification.dart';
|
||||
import 'package:appflowy/features/shared_section/data/share_pages_repository.dart';
|
||||
import 'package:appflowy/features/shared_section/models/shared_page.dart';
|
||||
import 'package:appflowy/features/util/extensions.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'shared_section_bloc.freezed.dart';
|
||||
|
||||
class SharedSectionBloc extends Bloc<SharedSectionEvent, SharedSectionState> {
|
||||
SharedSectionBloc({
|
||||
required this.repository,
|
||||
required this.workspaceId,
|
||||
this.enablePolling = false,
|
||||
this.pollingIntervalSeconds = 30,
|
||||
}) : super(SharedSectionState.initial()) {
|
||||
on<_Init>(_onInit);
|
||||
on<_Refresh>(_onRefresh);
|
||||
on<_UpdateSharedPages>(_onUpdateSharedPages);
|
||||
}
|
||||
|
||||
final String workspaceId;
|
||||
|
||||
// The repository to fetch the shared views.
|
||||
// If you need to test this bloc, you can add your own repository implementation.
|
||||
final SharePagesRepository repository;
|
||||
|
||||
// Used to listen for shared view updates.
|
||||
late final FolderNotificationListener _folderNotificationListener;
|
||||
|
||||
// Since the backend doesn't provide a way to listen for shared view updates (websocket with shared view updates is not implemented yet),
|
||||
// we need to poll the shared views periodically.
|
||||
final bool enablePolling;
|
||||
|
||||
// The interval of polling the shared views.
|
||||
final int pollingIntervalSeconds;
|
||||
|
||||
Timer? _pollingTimer;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _folderNotificationListener.stop();
|
||||
_pollingTimer?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
|
||||
Future<void> _onInit(
|
||||
_Init event,
|
||||
Emitter<SharedSectionState> emit,
|
||||
) async {
|
||||
_initFolderNotificationListener();
|
||||
_startPollingIfNeeded();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoading: true,
|
||||
errorMessage: '',
|
||||
),
|
||||
);
|
||||
final result = await repository.getSharedPages();
|
||||
result.fold(
|
||||
(pages) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
sharedPages: pages,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefresh(
|
||||
_Refresh event,
|
||||
Emitter<SharedSectionState> emit,
|
||||
) async {
|
||||
final result = await repository.getSharedPages();
|
||||
|
||||
result.fold(
|
||||
(pages) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
sharedPages: pages,
|
||||
),
|
||||
);
|
||||
},
|
||||
(error) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
errorMessage: error.msg,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onUpdateSharedPages(
|
||||
_UpdateSharedPages event,
|
||||
Emitter<SharedSectionState> emit,
|
||||
) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
sharedPages: event.sharedPages,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _initFolderNotificationListener() {
|
||||
_folderNotificationListener = FolderNotificationListener(
|
||||
objectId: workspaceId,
|
||||
handler: (notification, result) {
|
||||
if (notification == FolderNotification.DidUpdateSharedViews) {
|
||||
final response = result.fold(
|
||||
(payload) {
|
||||
final repeatedSharedViews =
|
||||
RepeatedSharedViewResponsePB.fromBuffer(payload);
|
||||
return repeatedSharedViews;
|
||||
},
|
||||
(error) => null,
|
||||
);
|
||||
if (response != null) {
|
||||
add(
|
||||
SharedSectionEvent.updateSharedPages(
|
||||
sharedPages: response.sharedPages,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startPollingIfNeeded() {
|
||||
_pollingTimer?.cancel();
|
||||
if (enablePolling && pollingIntervalSeconds > 0) {
|
||||
_pollingTimer = Timer.periodic(
|
||||
Duration(seconds: pollingIntervalSeconds),
|
||||
(_) {
|
||||
add(const SharedSectionEvent.refresh());
|
||||
|
||||
Log.debug('Polling shared views');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SharedSectionEvent with _$SharedSectionEvent {
|
||||
/// Initialize, it will create a folder notification listener to listen for shared view updates.
|
||||
/// Also, it will fetch the shared pages from the repository.
|
||||
const factory SharedSectionEvent.init() = _Init;
|
||||
|
||||
/// Refresh, it will re-fetch the shared pages from the repository.
|
||||
const factory SharedSectionEvent.refresh() = _Refresh;
|
||||
|
||||
/// Update the shared pages in the state.
|
||||
const factory SharedSectionEvent.updateSharedPages({
|
||||
required SharedPages sharedPages,
|
||||
}) = _UpdateSharedPages;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SharedSectionState with _$SharedSectionState {
|
||||
const factory SharedSectionState({
|
||||
@Default([]) SharedPages sharedPages,
|
||||
@Default(false) bool isLoading,
|
||||
@Default('') String errorMessage,
|
||||
}) = _SharedSectionState;
|
||||
|
||||
const SharedSectionState._();
|
||||
|
||||
factory SharedSectionState.initial() => const SharedSectionState();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
|
||||
typedef SharedPages = List<SharedPage>;
|
||||
|
||||
class SharedPage {
|
||||
SharedPage({
|
||||
required this.view,
|
||||
required this.accessLevel,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final ShareAccessLevel accessLevel;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import 'package:appflowy/features/shared_section/data/rust_share_pagers_repository.dart';
|
||||
import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart';
|
||||
import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart';
|
||||
import 'package:appflowy/features/shared_section/presentation/widgets/shared_pages_list.dart';
|
||||
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart';
|
||||
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_header.dart';
|
||||
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SharedSection extends StatelessWidget {
|
||||
const SharedSection({
|
||||
super.key,
|
||||
required this.workspaceId,
|
||||
});
|
||||
|
||||
final String workspaceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => SharedSectionBloc(
|
||||
workspaceId: workspaceId,
|
||||
repository: RustSharePagesRepository(),
|
||||
enablePolling: true,
|
||||
)..add(const SharedSectionEvent.init()),
|
||||
child: BlocBuilder<SharedSectionBloc, SharedSectionState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const SharedSectionLoading();
|
||||
}
|
||||
|
||||
if (state.errorMessage.isNotEmpty) {
|
||||
return SharedSectionError(errorMessage: state.errorMessage);
|
||||
}
|
||||
|
||||
// hide the shared section if there are no shared pages
|
||||
if (state.sharedPages.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Shared header
|
||||
const SharedSectionHeader(),
|
||||
|
||||
// Shared pages list
|
||||
SharedPagesList(sharedPages: state.sharedPages),
|
||||
|
||||
// Refresh button, for debugging only
|
||||
if (kDebugMode)
|
||||
RefreshSharedSectionButton(
|
||||
onTap: () {
|
||||
context.read<SharedSectionBloc>().add(
|
||||
const SharedSectionEvent.refresh(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class RefreshSharedSectionButton extends StatelessWidget {
|
||||
const RefreshSharedSectionButton({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return AFTextMenuItem(
|
||||
leading: Icon(
|
||||
Icons.refresh,
|
||||
size: 20,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
),
|
||||
title: LocaleKeys.shareSection_refresh.tr(),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import 'package:appflowy/features/shared_section/models/shared_page.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class SharedPagesList extends StatelessWidget {
|
||||
const SharedPagesList({
|
||||
super.key,
|
||||
required this.sharedPages,
|
||||
});
|
||||
|
||||
final SharedPages sharedPages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: sharedPages.map((sharedPage) {
|
||||
final view = sharedPage.view;
|
||||
return ViewItem(
|
||||
key: ValueKey(view.id),
|
||||
spaceType: FolderSpaceType.public,
|
||||
isFirstChild: view.id == sharedPages.first.view.id,
|
||||
view: view,
|
||||
level: 0,
|
||||
isDraggable: false, // disable draggable for shared pages
|
||||
leftPadding: HomeSpaceViewSizes.leftPadding,
|
||||
isFeedback: false,
|
||||
onSelected: (context, view) {
|
||||
if (HardwareKeyboard.instance.isControlPressed) {
|
||||
context.read<TabsBloc>().openTab(view);
|
||||
}
|
||||
context.read<TabsBloc>().openPlugin(view);
|
||||
},
|
||||
onTertiarySelected: (context, view) =>
|
||||
context.read<TabsBloc>().openTab(view),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SharedSectionError extends StatelessWidget {
|
||||
const SharedSectionError({
|
||||
super.key,
|
||||
required this.errorMessage,
|
||||
});
|
||||
|
||||
final String errorMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
errorMessage,
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.warning,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SharedSectionHeader extends StatelessWidget {
|
||||
const SharedSectionHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: theme.spacing.xs,
|
||||
bottom: theme.spacing.s,
|
||||
),
|
||||
child: Text(
|
||||
LocaleKeys.shareSection_shared.tr(),
|
||||
style: theme.textStyle.caption.enhanced(
|
||||
color: theme.textColorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SharedSectionLoading extends StatelessWidget {
|
||||
const SharedSectionLoading({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: theme.iconColorScheme.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:appflowy/features/share_tab/data/models/models.dart';
|
||||
import 'package:appflowy/features/shared_section/models/shared_page.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart' as folder;
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'
|
||||
as user;
|
||||
|
||||
extension RepeatedSharedViewResponsePBExtension
|
||||
on RepeatedSharedViewResponsePB {
|
||||
SharedPages get sharedPages {
|
||||
return sharedViews.map((e) => e.sharedPage).toList();
|
||||
}
|
||||
}
|
||||
|
||||
extension SharedViewPBExtension on SharedViewPB {
|
||||
SharedPage get sharedPage {
|
||||
return SharedPage(
|
||||
view: view,
|
||||
accessLevel: accessLevel.shareAccessLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension RepeatedSharedUserPBExtension on RepeatedSharedUserPB {
|
||||
SharedUsers get sharedUsers {
|
||||
return items.map((e) => e.sharedUser).toList();
|
||||
}
|
||||
}
|
||||
|
||||
extension SharedUserPBExtension on SharedUserPB {
|
||||
SharedUser get sharedUser {
|
||||
return SharedUser(
|
||||
email: email,
|
||||
name: name,
|
||||
accessLevel: accessLevel.shareAccessLevel,
|
||||
role: role.shareRole,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension AFAccessLevelPBExtension on AFAccessLevelPB {
|
||||
ShareAccessLevel get shareAccessLevel {
|
||||
switch (this) {
|
||||
case AFAccessLevelPB.ReadOnly:
|
||||
return ShareAccessLevel.readOnly;
|
||||
case AFAccessLevelPB.ReadAndComment:
|
||||
return ShareAccessLevel.readAndComment;
|
||||
case AFAccessLevelPB.ReadAndWrite:
|
||||
return ShareAccessLevel.readAndWrite;
|
||||
case AFAccessLevelPB.FullAccess:
|
||||
return ShareAccessLevel.fullAccess;
|
||||
default:
|
||||
throw Exception('Unknown share role: $this');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareAccessLevelExtension on ShareAccessLevel {
|
||||
AFAccessLevelPB get accessLevel {
|
||||
switch (this) {
|
||||
case ShareAccessLevel.readOnly:
|
||||
return AFAccessLevelPB.ReadOnly;
|
||||
case ShareAccessLevel.readAndComment:
|
||||
return AFAccessLevelPB.ReadAndComment;
|
||||
case ShareAccessLevel.readAndWrite:
|
||||
return AFAccessLevelPB.ReadAndWrite;
|
||||
case ShareAccessLevel.fullAccess:
|
||||
return AFAccessLevelPB.FullAccess;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AFRolePBExtension on AFRolePB {
|
||||
ShareRole get shareRole {
|
||||
switch (this) {
|
||||
case AFRolePB.Guest:
|
||||
return ShareRole.guest;
|
||||
case AFRolePB.Member:
|
||||
return ShareRole.member;
|
||||
case AFRolePB.Owner:
|
||||
return ShareRole.owner;
|
||||
default:
|
||||
throw Exception('Unknown share role: $this');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareRoleExtension on ShareRole {
|
||||
user.AFRolePB get userRole {
|
||||
switch (this) {
|
||||
case ShareRole.guest:
|
||||
return user.AFRolePB.Guest;
|
||||
case ShareRole.member:
|
||||
return user.AFRolePB.Member;
|
||||
case ShareRole.owner:
|
||||
return user.AFRolePB.Owner;
|
||||
}
|
||||
}
|
||||
|
||||
folder.AFRolePB get folderRole {
|
||||
switch (this) {
|
||||
case ShareRole.guest:
|
||||
return folder.AFRolePB.Guest;
|
||||
case ShareRole.member:
|
||||
return folder.AFRolePB.Member;
|
||||
case ShareRole.owner:
|
||||
return folder.AFRolePB.Owner;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class MobileViewPage extends StatefulWidget {
|
||||
this.fixedTitle,
|
||||
this.showMoreButton = true,
|
||||
this.blockId,
|
||||
this.bodyPaddingTop = 0.0,
|
||||
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
|
||||
});
|
||||
|
||||
@@ -50,6 +51,7 @@ class MobileViewPage extends StatefulWidget {
|
||||
final Map<String, dynamic>? arguments;
|
||||
final bool showMoreButton;
|
||||
final String? blockId;
|
||||
final double bodyPaddingTop;
|
||||
final List<PickerTabType> tabs;
|
||||
|
||||
// only used in row page
|
||||
@@ -176,7 +178,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: isDocument,
|
||||
appBar: appBar,
|
||||
body: body,
|
||||
body: Padding(
|
||||
padding: EdgeInsets.only(top: widget.bodyPaddingTop),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,7 +438,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
defaultScrollNotificationPredicate(notification)) {
|
||||
final ScrollMetrics metrics = notification.metrics;
|
||||
double height = MediaQuery.of(context).padding.top;
|
||||
double height =
|
||||
MediaQuery.of(context).padding.top + widget.bodyPaddingTop;
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
height += AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight;
|
||||
}
|
||||
|
||||
+3
-3
@@ -8,14 +8,14 @@ import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/editor/util/link_util.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_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:appflowy_editor/src/editor/util/link_util.dart';
|
||||
|
||||
class MobileBottomSheetEditLinkWidget extends StatefulWidget {
|
||||
const MobileBottomSheetEditLinkWidget({
|
||||
@@ -410,7 +410,7 @@ class _MobileBottomSheetEditLinkWidgetState
|
||||
BoxDecoration buildBorderDecoration() {
|
||||
return BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(theme.borderRadius.l),
|
||||
border: Border.all(color: theme.borderColorScheme.greyPrimary),
|
||||
border: Border.all(color: theme.borderColorScheme.primary),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -59,6 +59,7 @@ Future<T?> showMobileBottomSheet<T>(
|
||||
double initialChildSize = 0.51,
|
||||
double bottomSheetPadding = 0,
|
||||
bool enablePadding = true,
|
||||
WidgetBuilder? dragHandleBuilder,
|
||||
}) async {
|
||||
assert(
|
||||
showHeader ||
|
||||
@@ -102,7 +103,7 @@ Future<T?> showMobileBottomSheet<T>(
|
||||
// ----- header area -----
|
||||
if (showDragHandle) {
|
||||
children.add(
|
||||
const DragHandle(),
|
||||
dragHandleBuilder?.call(context) ?? const DragHandle(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class MobileSpaceMenuItem extends StatelessWidget {
|
||||
currentSpace: context.read<SpaceBloc>().state.currentSpace,
|
||||
),
|
||||
onTap: () {
|
||||
context.read<SpaceBloc>().add(SpaceEvent.open(space));
|
||||
context.read<SpaceBloc>().add(SpaceEvent.open(space: space));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,6 +19,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'home/mobile_home_page.dart';
|
||||
import 'search/mobile_search_page.dart';
|
||||
|
||||
enum BottomNavigationBarActionType {
|
||||
home,
|
||||
notificationMultiSelect,
|
||||
@@ -38,6 +41,14 @@ enum BottomNavigationBarItemType {
|
||||
notification;
|
||||
|
||||
String get label => name;
|
||||
String? get routeName {
|
||||
return switch (this) {
|
||||
home => MobileHomeScreen.routeName,
|
||||
search => MobileSearchScreen.routeName,
|
||||
notification => MobileNotificationsScreenV2.routeName,
|
||||
add => null,
|
||||
};
|
||||
}
|
||||
|
||||
ValueKey get valueKey {
|
||||
return ValueKey(label);
|
||||
|
||||
+144
-15
@@ -2,49 +2,78 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.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/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'mobile_search_summary_cell.dart';
|
||||
|
||||
class MobileSearchAskAiEntrance extends StatelessWidget {
|
||||
const MobileSearchAskAiEntrance({super.key, this.query});
|
||||
final String? query;
|
||||
const MobileSearchAskAiEntrance({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
|
||||
if (bloc == null || state == null) return _AskAIFor();
|
||||
|
||||
final generatingAIOverview = state.generatingAIOverview;
|
||||
if (generatingAIOverview) return _AISearching();
|
||||
|
||||
final hasMockSummary = _mockSummary?.isNotEmpty ?? false,
|
||||
hasSummaries = state.resultSummaries.isNotEmpty;
|
||||
if (hasMockSummary || hasSummaries) return _AIOverview();
|
||||
|
||||
return _AskAIFor();
|
||||
}
|
||||
}
|
||||
|
||||
class _AskAIFor extends StatelessWidget {
|
||||
const _AskAIFor();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
spaceM = theme.spacing.m,
|
||||
spaceL = theme.spacing.l;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context
|
||||
.read<CommandPaletteBloc?>()
|
||||
?.add(CommandPaletteEvent.gointToAskAI());
|
||||
?.add(CommandPaletteEvent.goingToAskAI());
|
||||
mobileCreateNewAIChatNotifier.value =
|
||||
mobileCreateNewAIChatNotifier.value + 1;
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 48,
|
||||
margin: EdgeInsets.only(top: 16),
|
||||
padding: EdgeInsets.fromLTRB(8, 12, 8, 12),
|
||||
margin: EdgeInsets.only(top: spaceM),
|
||||
padding: EdgeInsets.all(spaceL),
|
||||
child: Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_ai_chat_icon_m,
|
||||
size: Size.square(24),
|
||||
blendMode: null,
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.m_home_ai_chat_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
HSpace(12),
|
||||
buildText(theme),
|
||||
HSpace(8),
|
||||
buildText(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildText(AppFlowyThemeData theme) {
|
||||
final queryText = query ?? '';
|
||||
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(),
|
||||
@@ -74,3 +103,103 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AISearching extends StatelessWidget {
|
||||
const _AISearching();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
spaceM = theme.spacing.m,
|
||||
spaceL = theme.spacing.l;
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: spaceM),
|
||||
padding: EdgeInsets.all(spaceL),
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.m_home_ai_chat_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.search_searching.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AIOverview extends StatelessWidget {
|
||||
const _AIOverview();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
|
||||
final summaries = _mockSummary ?? state?.resultSummaries ?? [];
|
||||
if (summaries.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
spaceM = theme.spacing.m,
|
||||
spaceL = theme.spacing.l;
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: spaceM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
VSpace(spaceM),
|
||||
buildHeader(context),
|
||||
VSpace(spaceL),
|
||||
LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
final summary = summaries.first;
|
||||
return MobileSearchSummaryCell(
|
||||
key: ValueKey(summary.content.trim()),
|
||||
summary: summary,
|
||||
maxWidth: constrains.maxWidth,
|
||||
theme: AppFlowyTheme.of(context),
|
||||
textStyle: theme.textStyle.heading4
|
||||
.standard(color: theme.textColorScheme.primary)
|
||||
.copyWith(height: 22 / 16),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildHeader(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_searching_icon_m,
|
||||
size: Size.square(20),
|
||||
blendMode: null,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_aiOverview.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.primary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<SearchSummaryPB>? _mockSummary;
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.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/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
@@ -13,18 +13,26 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'mobile_view_ancestors.dart';
|
||||
|
||||
class MobileSearchResultCell extends StatelessWidget {
|
||||
const MobileSearchResultCell({super.key, required this.item, this.query});
|
||||
const MobileSearchResultCell({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.query,
|
||||
this.view,
|
||||
});
|
||||
final SearchResultItem item;
|
||||
final ViewPB? view;
|
||||
final String? query;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
textColor = theme.textColorScheme.primary;
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final commandPaletteState = context.read<CommandPaletteBloc>().state;
|
||||
final displayName = item.displayName.isEmpty
|
||||
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
|
||||
: item.displayName;
|
||||
final titleStyle = theme.textStyle.heading4
|
||||
.standard(color: theme.textColorScheme.primary)
|
||||
.copyWith(height: 24 / 16);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
child: Row(
|
||||
@@ -32,7 +40,10 @@ class MobileSearchResultCell extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(child: buildIcon(theme)),
|
||||
child: Center(
|
||||
child: (view?.toSearchResultItem().icon ?? item.icon)
|
||||
.buildIcon(context),
|
||||
),
|
||||
),
|
||||
HSpace(12),
|
||||
Flexible(
|
||||
@@ -44,12 +55,10 @@ class MobileSearchResultCell extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: buildHighLightSpan(
|
||||
content: displayName,
|
||||
normal: theme.textStyle.heading4.standard(color: textColor),
|
||||
highlight: theme.textStyle.heading4
|
||||
.standard(color: textColor)
|
||||
.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
normal: titleStyle,
|
||||
highlight: titleStyle.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
buildPath(commandPaletteState, theme),
|
||||
@@ -62,30 +71,24 @@ class MobileSearchResultCell extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIcon(AppFlowyThemeData theme) {
|
||||
final icon = item.icon;
|
||||
if (icon.ty == ResultIconTypePB.Emoji) {
|
||||
return icon.getIcon(size: 20) ?? SizedBox.shrink();
|
||||
} else {
|
||||
return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ??
|
||||
SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) {
|
||||
return BlocProvider(
|
||||
key: ValueKey(item.id),
|
||||
create: (context) => ViewAncestorBloc(item.id),
|
||||
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
|
||||
builder: (context, state) {
|
||||
final ancestors = state.ancestor.ancestors;
|
||||
if(ancestors.isEmpty) return const SizedBox.shrink();
|
||||
List<String> displayPath = ancestors.map((e) => e.name).toList();
|
||||
if (ancestors.length > 2) {
|
||||
displayPath = [ancestors.first.name, '...', ancestors.last.name];
|
||||
}
|
||||
return Text(
|
||||
displayPath.join(' / '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
.standard(color: theme.textColorScheme.tertiary),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -101,9 +104,9 @@ class MobileSearchResultCell extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: buildHighLightSpan(
|
||||
content: item.content,
|
||||
normal: theme.textStyle.heading4
|
||||
normal: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.secondary),
|
||||
highlight: theme.textStyle.heading4
|
||||
highlight: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.primary)
|
||||
.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension MobileSearchIconItemExtension on ResultIconPB {
|
||||
Widget? buildIcon(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
|
||||
if (ty == ResultIconTypePB.Emoji) {
|
||||
return SizedBox(
|
||||
width: 20,
|
||||
child: getIcon(size: 20) ?? SizedBox.shrink(),
|
||||
);
|
||||
} else {
|
||||
return getIcon(
|
||||
size: 20,
|
||||
iconColor: theme.iconColorScheme.secondary,
|
||||
) ??
|
||||
SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,39 +97,43 @@ class _MobileSearchPageState extends State<MobileSearchPage> {
|
||||
builder: (context, state) {
|
||||
return SafeArea(
|
||||
child: Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MobileSearchTextfield(
|
||||
focusNode: focusNode,
|
||||
hintText: enableShowAISearch
|
||||
? LocaleKeys.search_searchOrAskAI.tr()
|
||||
: LocaleKeys.search_label.tr(),
|
||||
query: state.query ?? '',
|
||||
onChanged: (value) =>
|
||||
context.read<CommandPaletteBloc>().add(
|
||||
CommandPaletteEvent.searchChanged(search: value),
|
||||
),
|
||||
),
|
||||
if (enableShowAISearch)
|
||||
MobileSearchAskAiEntrance(query: state.query),
|
||||
Flexible(
|
||||
child: NotificationListener(
|
||||
child: MobileSearchResult(),
|
||||
onNotification: (t) {
|
||||
if (t is ScrollUpdateNotification) {
|
||||
if (focusNode.hasFocus) {
|
||||
focusNode.unfocus();
|
||||
}
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MobileSearchTextfield(
|
||||
focusNode: focusNode,
|
||||
hintText: enableShowAISearch
|
||||
? LocaleKeys.search_searchOrAskAI.tr()
|
||||
: LocaleKeys.search_label.tr(),
|
||||
query: state.query ?? '',
|
||||
onChanged: (value) => context.read<CommandPaletteBloc>().add(
|
||||
CommandPaletteEvent.searchChanged(search: value),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: NotificationListener(
|
||||
onNotification: (t) {
|
||||
if (t is ScrollUpdateNotification) {
|
||||
if (focusNode.hasFocus) {
|
||||
focusNode.unfocus();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
return true;
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
if (enableShowAISearch) MobileSearchAskAiEntrance(),
|
||||
MobileSearchResult(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
|
||||
import 'package:appflowy/startup/tasks/app_widget.dart';
|
||||
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
|
||||
class SearchSourceReferenceBottomSheet extends StatelessWidget {
|
||||
const SearchSourceReferenceBottomSheet(this.sources, {super.key});
|
||||
|
||||
final List<SearchSourcePB> sources;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageReferenceList(
|
||||
sources: sources,
|
||||
onTap: (id) async {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final view = (await ViewBackendService.getView(id)).toNullable();
|
||||
if (view == null) {
|
||||
showToastNotification(
|
||||
message: LocaleKeys.search_somethingWentWrong.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await showMobileBottomSheet(
|
||||
AppGlobals.rootNavKey.currentContext ?? context,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
enableDraggableScrollable: true,
|
||||
maxChildSize: 0.95,
|
||||
minChildSize: 0.95,
|
||||
initialChildSize: 0.95,
|
||||
backgroundColor: theme.surfaceColorScheme.primary,
|
||||
dragHandleBuilder: (_) => const _DragHandler(),
|
||||
builder: (_) => SizedBox(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
child: MobileViewPage(
|
||||
id: id,
|
||||
viewLayout: view.layout,
|
||||
title: view.nameOrDefault,
|
||||
tabs: PickerTabType.values,
|
||||
bodyPaddingTop:
|
||||
AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PageReferenceList extends StatelessWidget {
|
||||
const PageReferenceList({
|
||||
super.key,
|
||||
required this.sources,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<SearchSourcePB> sources;
|
||||
final ValueChanged<String> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 8),
|
||||
child: Text(
|
||||
LocaleKeys.commandPalette_aiOverviewSource.tr(),
|
||||
style: theme.textStyle.body.enhanced(
|
||||
color: theme.textColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const VSpace(6),
|
||||
ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
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 FlowyButton(
|
||||
onTap: () => onTap.call(source.id),
|
||||
margin: EdgeInsets.zero,
|
||||
text: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (index != 0) AFDivider(),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: spaceL,
|
||||
horizontal: sapceM,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 20,
|
||||
child: Center(child: buildIcon(source.icon, theme)),
|
||||
),
|
||||
HSpace(12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textStyle.heading4.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DragHandler extends StatelessWidget {
|
||||
const _DragHandler();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 4,
|
||||
width: 40,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+125
-120
@@ -3,12 +3,10 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/tab.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/recent/recent_views_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
@@ -36,9 +34,8 @@ class MobileSearchRecentList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final commandPaletteState = context.read<CommandPaletteBloc>().state;
|
||||
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final trashIdSet = commandPaletteState.trash.map((e) => e.id).toSet();
|
||||
return BlocProvider(
|
||||
create: (context) =>
|
||||
@@ -49,31 +46,35 @@ class MobileSearchRecentList extends StatelessWidget {
|
||||
.map((e) => e.item)
|
||||
.where((e) => !trashIdSet.contains(e.id))
|
||||
.toList();
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
Text(
|
||||
LocaleKeys.sideBar_recent.tr(),
|
||||
style: theme.textStyle.body
|
||||
.enhanced(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
const VSpace(4),
|
||||
Column(
|
||||
children: List.generate(recentViews.length, (index) {
|
||||
final view = recentViews[index];
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _goToView(context, view),
|
||||
child: MobileSearchResultCell(
|
||||
item: view.toSearchResultItem(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
Text(
|
||||
LocaleKeys.sideBar_recent.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.secondary)
|
||||
.copyWith(
|
||||
letterSpacing: 0.2,
|
||||
height: 24 / 16,
|
||||
),
|
||||
),
|
||||
const VSpace(4),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(recentViews.length, (index) {
|
||||
final view = recentViews[index];
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _goToView(context, view),
|
||||
child: MobileSearchResultCell(
|
||||
item: view.toSearchResultItem(),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -81,108 +82,112 @@ class MobileSearchRecentList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class MobileSearchResultList extends StatefulWidget {
|
||||
class MobileSearchResultList extends StatelessWidget {
|
||||
const MobileSearchResultList({super.key});
|
||||
|
||||
@override
|
||||
State<MobileSearchResultList> createState() => _MobileSearchResultListState();
|
||||
}
|
||||
|
||||
class _MobileSearchResultListState extends State<MobileSearchResultList> {
|
||||
late final SearchResultListBloc bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
bloc = SearchResultListBloc();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
bloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.read<CommandPaletteBloc>().state,
|
||||
theme = AppFlowyTheme.of(context);
|
||||
final isSearching = state.searching,
|
||||
items = state.combinedResponseItems.values.toList(),
|
||||
hasData = items.isNotEmpty;
|
||||
if (isSearching && !hasData) {
|
||||
return Center(child: CircularProgressIndicator.adaptive());
|
||||
} else if (!hasData) {
|
||||
return buildNoResult(state.query ?? '');
|
||||
final isSearching = state.searching || state.generatingAIOverview,
|
||||
cachedViews = state.cachedViews;
|
||||
List<SearchResultItem> displayItems =
|
||||
state.combinedResponseItems.values.toList();
|
||||
if (cachedViews.isNotEmpty) {
|
||||
displayItems =
|
||||
displayItems.where((item) => cachedViews[item.id] != null).toList();
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_bestMatches.tr(),
|
||||
style: theme.textStyle.body
|
||||
.enhanced(color: theme.textColorScheme.secondary),
|
||||
),
|
||||
const VSpace(4),
|
||||
Column(
|
||||
children: List.generate(items.length, (index) {
|
||||
final item = items[index];
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
final view = await ViewBackendService.getView(item.id)
|
||||
.fold((s) => s, (s) => null);
|
||||
if (view != null && context.mounted) {
|
||||
await _goToView(context, view);
|
||||
} else {
|
||||
Log.error(
|
||||
'tapping search result, view not found: ${item.id}',
|
||||
);
|
||||
}
|
||||
},
|
||||
child: MobileSearchResultCell(
|
||||
item: item,
|
||||
query: state.query,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
final hasData = displayItems.isNotEmpty;
|
||||
|
||||
if (isSearching && !hasData) {
|
||||
return SizedBox(
|
||||
height: 400,
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
);
|
||||
} else if (!hasData) {
|
||||
return _NoResult();
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const VSpace(16),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_bestMatches.tr(),
|
||||
style: theme.textStyle.heading4
|
||||
.enhanced(color: theme.textColorScheme.secondary)
|
||||
.copyWith(
|
||||
letterSpacing: 0.2,
|
||||
height: 24 / 16,
|
||||
),
|
||||
),
|
||||
const VSpace(4),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
final item = displayItems[index];
|
||||
final view = state.cachedViews[item.id];
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
if (view != null && context.mounted) {
|
||||
await _goToView(context, view);
|
||||
} else {
|
||||
showToastNotification(
|
||||
message: LocaleKeys.search_pageNotExist.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
Log.error(
|
||||
'tapping search result, view not found: ${item.id}',
|
||||
);
|
||||
}
|
||||
},
|
||||
child: MobileSearchResultCell(
|
||||
item: item,
|
||||
view: view,
|
||||
query: state.query,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => AFDivider(),
|
||||
itemCount: displayItems.length,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildNoResult(String query) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
textColor = theme.textColorScheme.secondary;
|
||||
class _NoResult extends StatelessWidget {
|
||||
const _NoResult();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final textColor = theme.textColorScheme.secondary;
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox(
|
||||
height: 140,
|
||||
child: Column(
|
||||
children: [
|
||||
const VSpace(48),
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const VSpace(12),
|
||||
Text(
|
||||
LocaleKeys.search_noResultForSearching.tr(),
|
||||
style: theme.textStyle.body.enhanced(color: textColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
textAlign: TextAlign.center,
|
||||
LocaleKeys.search_noResultForSearchingHint.tr(),
|
||||
style: theme.textStyle.caption.standard(color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const VSpace(48),
|
||||
FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
size: Size.square(24),
|
||||
),
|
||||
const VSpace(12),
|
||||
Text(
|
||||
LocaleKeys.search_noResultForSearching.tr(),
|
||||
style: theme.textStyle.heading4.enhanced(color: textColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
textAlign: TextAlign.center,
|
||||
LocaleKeys.search_noResultForSearchingHint.tr(),
|
||||
style: theme.textStyle.body.standard(color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
|
||||
import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart';
|
||||
import 'package:appflowy/startup/tasks/app_widget.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' hide TextDirection;
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'mobile_search_reference_bottom_sheet.dart';
|
||||
|
||||
class MobileSearchSummaryCell extends StatefulWidget {
|
||||
const MobileSearchSummaryCell({
|
||||
super.key,
|
||||
required this.summary,
|
||||
required this.maxWidth,
|
||||
required this.theme,
|
||||
required this.textStyle,
|
||||
});
|
||||
|
||||
final SearchSummaryPB summary;
|
||||
final double maxWidth;
|
||||
final AppFlowyThemeData theme;
|
||||
final TextStyle textStyle;
|
||||
|
||||
@override
|
||||
State<MobileSearchSummaryCell> createState() =>
|
||||
_MobileSearchSummaryCellState();
|
||||
}
|
||||
|
||||
class _MobileSearchSummaryCellState extends State<MobileSearchSummaryCell> {
|
||||
late TextPainter _painter;
|
||||
late _TextInfo _textInfo = _TextInfo.normal(summary.content);
|
||||
bool tappedShowMore = false;
|
||||
final maxLines = 4;
|
||||
|
||||
SearchSummaryPB get summary => widget.summary;
|
||||
double get maxWidth => widget.maxWidth;
|
||||
AppFlowyThemeData get theme => widget.theme;
|
||||
|
||||
TextStyle get textStyle => widget.textStyle;
|
||||
|
||||
TextStyle get showMoreStyle =>
|
||||
theme.textStyle.heading4.standard(color: theme.textColorScheme.secondary);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
refreshTextPainter();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MobileSearchSummaryCell oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.maxWidth != maxWidth) {
|
||||
refreshTextPainter();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_painter.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showReference = summary.sources.isNotEmpty;
|
||||
final query = summary.highlights.isEmpty
|
||||
? context.read<CommandPaletteBloc?>()?.state.query
|
||||
: summary.highlights;
|
||||
return _textInfo.build(
|
||||
context: context,
|
||||
normal: textStyle,
|
||||
more: showMoreStyle,
|
||||
summury: summary,
|
||||
query: query ?? '',
|
||||
showMore: () {
|
||||
setState(() {
|
||||
tappedShowMore = true;
|
||||
_textInfo = _TextInfo.normal(summary.content);
|
||||
});
|
||||
},
|
||||
normalWidgetSpan: showReference
|
||||
? WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => showPageReferences(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 15, 4),
|
||||
child: Container(
|
||||
width: 21,
|
||||
height: 15,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
overflowFadeCover: buildFadeCover(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFadeCover() {
|
||||
final fillColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
return Container(
|
||||
width: maxWidth,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
fillColor.withValues(alpha: 0),
|
||||
fillColor.withValues(alpha: 0.6 * 255),
|
||||
fillColor,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void refreshTextPainter() {
|
||||
final content = summary.content;
|
||||
if (!tappedShowMore) {
|
||||
_painter = TextPainter(
|
||||
text: TextSpan(text: content, style: textStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: maxLines,
|
||||
);
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void showPageReferences(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
showMobileBottomSheet(
|
||||
AppGlobals.rootNavKey.currentContext ?? context,
|
||||
showDragHandle: true,
|
||||
showDivider: false,
|
||||
enableDraggableScrollable: true,
|
||||
backgroundColor: theme.surfaceColorScheme.primary,
|
||||
builder: (_) => SearchSourceReferenceBottomSheet(summary.sources),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextInfo {
|
||||
_TextInfo({required this.text, required this.isOverflow});
|
||||
|
||||
_TextInfo.normal(this.text) : isOverflow = false;
|
||||
|
||||
_TextInfo.overflow(this.text) : isOverflow = true;
|
||||
|
||||
final String text;
|
||||
final bool isOverflow;
|
||||
|
||||
Widget build({
|
||||
required BuildContext context,
|
||||
required TextStyle normal,
|
||||
required TextStyle more,
|
||||
required VoidCallback showMore,
|
||||
required String query,
|
||||
required SearchSummaryPB summury,
|
||||
WidgetSpan? normalWidgetSpan,
|
||||
Widget? overflowFadeCover,
|
||||
}) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
if (isOverflow) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: showMore,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
SelectionArea(
|
||||
child: Text.rich(
|
||||
_buildHighLightSpan(
|
||||
content: text,
|
||||
normal: normal,
|
||||
query: query,
|
||||
highlight: normal.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (overflowFadeCover != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: overflowFadeCover,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.arrow_down_s,
|
||||
size: Size.square(20),
|
||||
color: theme.iconColorScheme.secondary,
|
||||
),
|
||||
),
|
||||
HSpace(8),
|
||||
Text(LocaleKeys.search_showMore.tr(), style: more),
|
||||
],
|
||||
),
|
||||
),
|
||||
VSpace(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SelectionArea(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
_buildHighLightSpan(
|
||||
content: text,
|
||||
normal: normal,
|
||||
query: query,
|
||||
highlight: normal.copyWith(
|
||||
backgroundColor: theme.fillColorScheme.themeSelect,
|
||||
),
|
||||
),
|
||||
if (normalWidgetSpan != null) normalWidgetSpan,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
VSpace(16),
|
||||
SizedBox(
|
||||
width: 156,
|
||||
height: 42,
|
||||
child: AFOutlinedButton.normal(
|
||||
borderRadius: 21,
|
||||
padding: EdgeInsets.zero,
|
||||
builder: (context, hovering, disabled) {
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
height: 22,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.chat_ai_page_s,
|
||||
size: Size.square(20),
|
||||
color: theme.iconColorScheme.secondary,
|
||||
),
|
||||
HSpace(8),
|
||||
Text(
|
||||
LocaleKeys.commandPalette_aiAskFollowUp.tr(),
|
||||
style: theme.textStyle.heading4.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onTap: () {
|
||||
context.read<CommandPaletteBloc?>()?.add(
|
||||
CommandPaletteEvent.goingToAskAI(
|
||||
sources: summury.sources,
|
||||
),
|
||||
);
|
||||
mobileCreateNewAIChatNotifier.value =
|
||||
mobileCreateNewAIChatNotifier.value + 1;
|
||||
},
|
||||
),
|
||||
),
|
||||
VSpace(16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+79
-51
@@ -1,9 +1,15 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart';
|
||||
import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/startup/tasks/af_navigator_observer.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'mobile_search_page.dart';
|
||||
|
||||
class MobileSearchTextfield extends StatefulWidget {
|
||||
const MobileSearchTextfield({
|
||||
@@ -23,83 +29,97 @@ class MobileSearchTextfield extends StatefulWidget {
|
||||
State<MobileSearchTextfield> createState() => _MobileSearchTextfieldState();
|
||||
}
|
||||
|
||||
class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
class _MobileSearchTextfieldState extends State<MobileSearchTextfield>
|
||||
with RouteAware {
|
||||
late final TextEditingController controller;
|
||||
final ValueNotifier<bool> hasFocusValueNotifier = ValueNotifier(true);
|
||||
|
||||
FocusNode get focusNode => widget.focusNode;
|
||||
late String lastPage = bottomNavigationBarItemType.value ?? '';
|
||||
String lastText = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = TextEditingController(text: widget.query);
|
||||
focusNode.addListener(onFocusChanged);
|
||||
controller.addListener(() {
|
||||
if (!mounted) return;
|
||||
widget.onChanged?.call(controller.text);
|
||||
if (lastText != controller.text) {
|
||||
widget.onChanged?.call(controller.text);
|
||||
lastText = controller.text;
|
||||
}
|
||||
});
|
||||
bottomNavigationBarItemType.addListener(onBackOrLeave);
|
||||
focusNode.requestFocus();
|
||||
makeSureHasFocus();
|
||||
getIt<AFNavigatorObserver>().addListener(onRoute);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
focusNode.removeListener(onFocusChanged);
|
||||
hasFocusValueNotifier.dispose();
|
||||
bottomNavigationBarItemType.removeListener(onBackOrLeave);
|
||||
getIt<AFNavigatorObserver>().removeListener(onRoute);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return SizedBox(
|
||||
return Container(
|
||||
height: 42,
|
||||
margin: EdgeInsets.symmetric(vertical: 8),
|
||||
padding: EdgeInsets.only(left: 4, right: 16),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: controller,
|
||||
builder: (context, _, __) {
|
||||
final hasText = controller.text.isNotEmpty;
|
||||
return Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (lastPage.isEmpty) return;
|
||||
// close the popup menu
|
||||
closePopupMenu();
|
||||
try {
|
||||
BottomNavigationBarItemType label =
|
||||
BottomNavigationBarItemType.values.byName(lastPage);
|
||||
if (label == BottomNavigationBarItemType.search) {
|
||||
label = BottomNavigationBarItemType.home;
|
||||
}
|
||||
if (label == BottomNavigationBarItemType.notification) {
|
||||
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
|
||||
}
|
||||
bottomNavigationBarItemType.value = label.label;
|
||||
final routeName = label.routeName;
|
||||
if (routeName != null) GoRouter.of(context).go(routeName);
|
||||
} on ArgumentError {
|
||||
Log.error(
|
||||
'lastPage: [$lastPage] cannot be converted to BottomNavigationBarItemType',
|
||||
);
|
||||
}
|
||||
},
|
||||
child: SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Center(
|
||||
child: FlowySvg(
|
||||
FlowySvgs.search_page_arrow_left_m,
|
||||
size: Size.square(20),
|
||||
color: theme.iconColorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
autofocus: true,
|
||||
focusNode: focusNode,
|
||||
textAlign: TextAlign.left,
|
||||
controller: controller,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
style: theme.textStyle.heading4.standard(
|
||||
color: theme.textColorScheme.primary,
|
||||
),
|
||||
decoration: buildInputDecoration(context),
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: hasFocusValueNotifier,
|
||||
builder: (context, hasFocus, __) {
|
||||
if (!hasFocus || !hasText) return SizedBox.shrink();
|
||||
return GestureDetector(
|
||||
onTap: () => focusNode.unfocus(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 42,
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
style: theme.textStyle.body
|
||||
.standard(color: theme.textColorScheme.action),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -112,15 +132,12 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
final showCancelIcon = controller.text.isNotEmpty;
|
||||
final border = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10.0)),
|
||||
borderSide: BorderSide(color: theme.borderColorScheme.greyTertiary),
|
||||
borderSide: BorderSide(color: theme.borderColorScheme.primary),
|
||||
);
|
||||
final enableBorder = border.copyWith(
|
||||
borderSide: BorderSide(color: theme.borderColorScheme.themeThick),
|
||||
);
|
||||
final hintStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
final hintStyle = theme.textStyle.heading4.standard(
|
||||
color: theme.textColorScheme.tertiary,
|
||||
);
|
||||
return InputDecoration(
|
||||
@@ -131,9 +148,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
border: border,
|
||||
enabledBorder: border,
|
||||
focusedBorder: enableBorder,
|
||||
prefixIconConstraints: BoxConstraints.loose(Size(34, 40)),
|
||||
prefixIconConstraints: BoxConstraints.loose(Size(38, 40)),
|
||||
prefixIcon: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 4, 10),
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 8, 10),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.m_home_search_icon_m,
|
||||
color: theme.iconColorScheme.secondary,
|
||||
@@ -150,10 +167,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.clear_s,
|
||||
blendMode: null,
|
||||
FlowySvgs.search_clear_m,
|
||||
color: theme.iconColorScheme.tertiary,
|
||||
size: Size.square(20),
|
||||
size: const Size.square(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -161,9 +177,12 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
);
|
||||
}
|
||||
|
||||
void onFocusChanged() {
|
||||
if (!mounted) return;
|
||||
hasFocusValueNotifier.value = focusNode.hasFocus;
|
||||
Future<void> makeSureHasFocus() async {
|
||||
if (!mounted || focusNode.hasFocus) return;
|
||||
focusNode.requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
makeSureHasFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void onBackOrLeave() {
|
||||
@@ -173,6 +192,15 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
|
||||
} else {
|
||||
focusNode.unfocus();
|
||||
controller.clear();
|
||||
lastPage = label ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
void onRoute(RouteInfo routeInfo) {
|
||||
final oldName = routeInfo.oldRoute?.settings.name;
|
||||
if (oldName != MobileSearchScreen.routeName) return;
|
||||
if (routeInfo is PushRouterInfo) {
|
||||
focusNode.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
-1
@@ -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) {
|
||||
@@ -26,7 +31,9 @@ class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
|
||||
}
|
||||
},
|
||||
);
|
||||
emit(state.copyWith(ancestor: ancester, isLoading: false));
|
||||
if (ancester != null) {
|
||||
emit(state.copyWith(ancestor: ancester, isLoading: false));
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -53,3 +60,79 @@ 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: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
TextPainter _buildTextPainter(String text, TextStyle style) => TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
|
||||
Widget buildOnelinePath(BuildContext context) {
|
||||
final ancestors = ancestor.ancestors;
|
||||
List<String> displayPath = ancestors.map((e) => e.name).toList();
|
||||
if (ancestors.length > 2) {
|
||||
displayPath = [ancestors.first.name, '...', ancestors.last.name];
|
||||
}
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final style = theme.textStyle.caption
|
||||
.standard(color: theme.textColorScheme.tertiary)
|
||||
.copyWith(letterSpacing: 0.1);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
HSpace(8),
|
||||
Text(
|
||||
'-',
|
||||
style: style.copyWith(
|
||||
color: theme.borderColorScheme.primaryHover,
|
||||
),
|
||||
),
|
||||
HSpace(8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayPath.join(' / '),
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class ViewAncestorCache {
|
||||
|
||||
final Map<String, ViewAncestor> _ancestors = {};
|
||||
|
||||
Future<ViewAncestor> getAncestor(
|
||||
Future<ViewAncestor?> getAncestor(
|
||||
String viewId, {
|
||||
ValueChanged<ViewAncestor>? onRefresh,
|
||||
}) async {
|
||||
@@ -23,23 +23,26 @@ class ViewAncestorCache {
|
||||
return _getAncestor(viewId);
|
||||
}
|
||||
|
||||
Future<ViewAncestor> _getAncestor(
|
||||
Future<ViewAncestor?> _getAncestor(
|
||||
String viewId, {
|
||||
ValueChanged<ViewAncestor>? onRefresh,
|
||||
}) async {
|
||||
final List<ViewPB> ancestors =
|
||||
final List<ViewPB>? ancestors =
|
||||
await ViewBackendService.getViewAncestors(viewId).fold(
|
||||
(s) => s.items
|
||||
.where((e) => e.parentViewId.isNotEmpty && e.id != viewId)
|
||||
.toList(),
|
||||
(f) => [],
|
||||
(f) => null,
|
||||
);
|
||||
final newAncestors = ViewAncestor(
|
||||
ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(),
|
||||
);
|
||||
_ancestors[viewId] = newAncestors;
|
||||
onRefresh?.call(newAncestors);
|
||||
return newAncestors;
|
||||
if (ancestors != null) {
|
||||
final newAncestors = ViewAncestor(
|
||||
ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(),
|
||||
);
|
||||
_ancestors[viewId] = newAncestors;
|
||||
onRefresh?.call(newAncestors);
|
||||
return newAncestors;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
|
||||
import 'package:appflowy/shared/af_role_pb_extension.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_email.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
|
||||
import 'package:appflowy/shared/af_role_pb_extension.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/user_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
|
||||
@@ -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,264 +13,303 @@ 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, promptId) async =>
|
||||
_handleSendMessage(message, format, metadata, promptId, 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,
|
||||
emit,
|
||||
),
|
||||
|
||||
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,
|
||||
String? promptId,
|
||||
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, promptId);
|
||||
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,
|
||||
Emitter<ChatState> emit,
|
||||
) {
|
||||
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);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.relatedQuestionsReady,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,25 +320,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 +332,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 +350,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 +407,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 +420,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) {
|
||||
@@ -417,37 +451,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
String? promptId,
|
||||
) 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, promptId).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 +503,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
|
||||
@@ -635,6 +556,7 @@ class ChatEvent with _$ChatEvent {
|
||||
required String message,
|
||||
PredefinedFormat? format,
|
||||
Map<String, dynamic>? metadata,
|
||||
String? promptId,
|
||||
}) = _SendMessage;
|
||||
const factory ChatEvent.finishSending() = _FinishSendMessage;
|
||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||
@@ -668,6 +590,9 @@ class ChatEvent with _$ChatEvent {
|
||||
) = _DidReceiveRelatedQueston;
|
||||
|
||||
const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
|
||||
|
||||
const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) =
|
||||
_OnAIFollowUp;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -58,6 +58,9 @@ enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
streamingAnswer,
|
||||
relatedQuestionsReady;
|
||||
|
||||
bool get isReady => this == ready || this == relatedQuestionsReady;
|
||||
}
|
||||
|
||||
class ChatFile extends Equatable {
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class MessageHeightConstants {
|
||||
static const String answerSuffix = '_ans';
|
||||
|
||||
static const String withoutMinHeightSuffix = '_without_min_height';
|
||||
|
||||
// This offset comes from the chat input box height + navigation bar height
|
||||
// It's used to calculate the minimum height for answer messages
|
||||
//
|
||||
// navigation bar height + last user message height
|
||||
// + last AI message height + chat input box height = screen height
|
||||
static const double defaultDesktopScreenOffset = 220.0;
|
||||
static const double defaultMobileScreenOffset = 304.0;
|
||||
|
||||
static const double relatedQuestionOffset = 72.0;
|
||||
}
|
||||
|
||||
class ChatMessageHeightManager {
|
||||
factory ChatMessageHeightManager() => _instance;
|
||||
|
||||
ChatMessageHeightManager._();
|
||||
|
||||
static final ChatMessageHeightManager _instance =
|
||||
ChatMessageHeightManager._();
|
||||
|
||||
final Map<String, double> _heightCache = <String, double>{};
|
||||
|
||||
double get defaultScreenOffset {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return MessageHeightConstants.defaultMobileScreenOffset;
|
||||
}
|
||||
return MessageHeightConstants.defaultDesktopScreenOffset;
|
||||
}
|
||||
|
||||
/// Cache a message height
|
||||
void cacheHeight({
|
||||
required String messageId,
|
||||
required double height,
|
||||
}) {
|
||||
if (messageId.isEmpty || height <= 0) {
|
||||
assert(false, 'messageId or height is invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
_heightCache[messageId] = height;
|
||||
}
|
||||
|
||||
void cacheWithoutMinHeight({
|
||||
required String messageId,
|
||||
required double height,
|
||||
}) {
|
||||
if (messageId.isEmpty || height <= 0) {
|
||||
assert(false, 'messageId or height is invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix] =
|
||||
height;
|
||||
}
|
||||
|
||||
double? getCachedHeight({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (messageId.isEmpty) return null;
|
||||
|
||||
final height = _heightCache[messageId];
|
||||
return height;
|
||||
}
|
||||
|
||||
double? getCachedWithoutMinHeight({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (messageId.isEmpty) return null;
|
||||
final height =
|
||||
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix];
|
||||
return height;
|
||||
}
|
||||
|
||||
/// Calculate minimum height for AI answer messages
|
||||
///
|
||||
/// For the user message, we don't need to calculate the minimum height
|
||||
double calculateMinHeight({
|
||||
required String messageId,
|
||||
required double screenHeight,
|
||||
}) {
|
||||
if (!isAnswerMessage(messageId)) return 0.0;
|
||||
|
||||
final originalMessageId = getOriginalMessageId(
|
||||
messageId: messageId,
|
||||
);
|
||||
final cachedHeight = getCachedHeight(
|
||||
messageId: originalMessageId,
|
||||
);
|
||||
|
||||
if (cachedHeight == null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final calculatedHeight = screenHeight - cachedHeight - defaultScreenOffset;
|
||||
return max(calculatedHeight, 0.0);
|
||||
}
|
||||
|
||||
/// Calculate minimum height for related question messages
|
||||
///
|
||||
/// For the user message, we don't need to calculate the minimum height
|
||||
double calculateRelatedQuestionMinHeight({
|
||||
required String messageId,
|
||||
}) {
|
||||
final cacheHeight = getCachedHeight(
|
||||
messageId: messageId,
|
||||
);
|
||||
final cacheHeightWithoutMinHeight = getCachedWithoutMinHeight(
|
||||
messageId: messageId,
|
||||
);
|
||||
double minHeight = 0;
|
||||
if (cacheHeight != null && cacheHeightWithoutMinHeight != null) {
|
||||
minHeight = cacheHeight -
|
||||
cacheHeightWithoutMinHeight -
|
||||
MessageHeightConstants.relatedQuestionOffset;
|
||||
}
|
||||
minHeight = max(minHeight, 0);
|
||||
return minHeight;
|
||||
}
|
||||
|
||||
bool isAnswerMessage(String messageId) {
|
||||
return messageId.endsWith(MessageHeightConstants.answerSuffix);
|
||||
}
|
||||
|
||||
/// Get the original message ID from an answer message ID
|
||||
///
|
||||
/// Answer message ID is like: "message_id_ans"
|
||||
/// Original message ID is like: "message_id"
|
||||
String getOriginalMessageId({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (!isAnswerMessage(messageId)) {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
return messageId.replaceAll(MessageHeightConstants.answerSuffix, '');
|
||||
}
|
||||
|
||||
void removeFromCache({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (messageId.isEmpty) return;
|
||||
|
||||
_heightCache.remove(messageId);
|
||||
|
||||
final answerMessageId = messageId + MessageHeightConstants.answerSuffix;
|
||||
_heightCache.remove(answerMessageId);
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_heightCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -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,126 @@
|
||||
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,
|
||||
String? promptId,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
|
||||
if (promptId != null) {
|
||||
payload.promptId = promptId;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Send a streaming message request to the server
|
||||
Future<FlowyResult<ChatMessagePB, FlowyError>> sendStreamRequest(
|
||||
String message,
|
||||
PredefinedFormat? format,
|
||||
String? promptId,
|
||||
) async {
|
||||
final payload = buildStreamPayload(message, format, promptId);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'application/chat_bloc.dart';
|
||||
import 'application/chat_entity.dart';
|
||||
import 'application/chat_member_bloc.dart';
|
||||
|
||||
class AIChatPage extends StatelessWidget {
|
||||
@@ -69,8 +68,7 @@ class AIChatPage extends StatelessWidget {
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
HardwareKeyboard.instance.isControlPressed) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
if (chatBloc.state.promptResponseState !=
|
||||
PromptResponseState.ready) {
|
||||
if (!chatBloc.state.promptResponseState.isReady) {
|
||||
chatBloc.add(ChatEvent.stopStream());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
+209
-253
@@ -1,15 +1,19 @@
|
||||
// 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: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';
|
||||
|
||||
import '../application/chat_message_height_manager.dart';
|
||||
import 'widgets/message_height_calculator.dart';
|
||||
|
||||
class ChatAnimatedList extends StatefulWidget {
|
||||
const ChatAnimatedList({
|
||||
@@ -36,34 +40,56 @@ class ChatAnimatedList extends StatefulWidget {
|
||||
final double scrollBottomPadding;
|
||||
|
||||
@override
|
||||
ChatAnimatedListState createState() => ChatAnimatedListState();
|
||||
State<ChatAnimatedList> createState() => _ChatAnimatedListState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
class _ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
|
||||
late final ChatController _chatController = Provider.of<ChatController>(
|
||||
late final ChatController chatController = Provider.of<ChatController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
late List<Message> oldList;
|
||||
late StreamSubscription<ChatOperation> operationsSubscription;
|
||||
|
||||
late final AnimationController _scrollToBottomController;
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
late final AnimationController scrollToBottomController;
|
||||
late final Animation<double> scrollToBottomAnimation;
|
||||
Timer? scrollToBottomShowTimer;
|
||||
|
||||
bool _userHasScrolled = false;
|
||||
bool _isScrollingToBottom = false;
|
||||
// String _lastInsertedMessageId = '';
|
||||
final ScrollOffsetController scrollOffsetController =
|
||||
ScrollOffsetController();
|
||||
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
|
||||
final ScrollOffsetListener scrollOffsetListener =
|
||||
ScrollOffsetListener.create();
|
||||
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
int lastUserMessageIndex = 0;
|
||||
bool isScrollingToBottom = false;
|
||||
|
||||
final loadPreviousMessagesDebounce = Debounce(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
int initialScrollIndex = 0;
|
||||
double initialAlignment = 1.0;
|
||||
List<Message> messages = [];
|
||||
|
||||
final ChatMessageHeightManager heightManager = ChatMessageHeightManager();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
oldList = List.from(chatController.messages);
|
||||
operationsSubscription = chatController.operationsStream.listen((event) {
|
||||
setState(() {
|
||||
messages = chatController.messages;
|
||||
});
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
assert(
|
||||
@@ -74,8 +100,9 @@ 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);
|
||||
oldList = List.from(chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.remove:
|
||||
assert(
|
||||
@@ -86,15 +113,16 @@ 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);
|
||||
oldList = List.from(chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.set:
|
||||
final newList = _chatController.messages;
|
||||
final newList = chatController.messages;
|
||||
|
||||
final updates = diffutil
|
||||
.calculateDiff<Message>(
|
||||
MessageListDiff(_oldList, newList),
|
||||
MessageListDiff(oldList, newList),
|
||||
)
|
||||
.getUpdatesWithData();
|
||||
|
||||
@@ -102,292 +130,220 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
_onDiffUpdate(updates.elementAt(i));
|
||||
}
|
||||
|
||||
_oldList = List.from(newList);
|
||||
oldList = List.from(newList);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
messages = chatController.messages;
|
||||
|
||||
scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
widget.scrollController.addListener(_handleLoadPreviousMessages);
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
_handleToggleScrollToBottom();
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomController.dispose();
|
||||
operationsSubscription.cancel();
|
||||
|
||||
_clearMessageHeightCache();
|
||||
|
||||
super.dispose();
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
widget.scrollController.removeListener(_handleLoadPreviousMessages);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
|
||||
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;
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
initialScrollIndex = messages.length;
|
||||
initialAlignment = 1.0;
|
||||
if (messages.length <= 2) {
|
||||
initialScrollIndex = 0;
|
||||
initialAlignment = 0.0;
|
||||
}
|
||||
|
||||
final Widget child = Stack(
|
||||
children: [
|
||||
ScrollablePositionedList.builder(
|
||||
scrollOffsetController: scrollOffsetController,
|
||||
itemScrollController: itemScrollController,
|
||||
initialScrollIndex: initialScrollIndex,
|
||||
initialAlignment: initialAlignment,
|
||||
scrollOffsetListener: scrollOffsetListener,
|
||||
itemPositionsListener: itemPositionsListener,
|
||||
physics: ClampingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
// the extra item is a vertical padding.
|
||||
itemCount: messages.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < 0 || index > messages.length) {
|
||||
Log.error('[chat animation list] index out of range: $index');
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
_handleToggleScrollToBottom();
|
||||
}
|
||||
if (index == messages.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
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(
|
||||
final message = messages[index];
|
||||
return MessageHeightCalculator(
|
||||
messageId: message.id,
|
||||
onHeightMeasured: _cacheMessageHeight,
|
||||
child: widget.itemBuilder(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
Tween<double>(begin: 1, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleScrollToBottom() {
|
||||
_isScrollingToBottom = true;
|
||||
_scrollToBottomController.reverse();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
final paddingHeight = height - widget.scrollBottomPadding;
|
||||
final offset =
|
||||
widget.scrollController.position.maxScrollExtent - paddingHeight;
|
||||
|
||||
if (widget.scrollToEndAnimationDuration == Duration.zero) {
|
||||
widget.scrollController.jumpTo(offset);
|
||||
} else {
|
||||
await widget.scrollController.animateTo(
|
||||
offset,
|
||||
duration: widget.scrollToEndAnimationDuration,
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
|
||||
if (!widget.scrollController.hasClients || !mounted) return;
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (_isScrollingToBottom) {
|
||||
// waiting for the ai answer message to be inserted
|
||||
if (lastUserMessageIndex == -1 ||
|
||||
lastUserMessageIndex + 1 >= messages.length) {
|
||||
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();
|
||||
}
|
||||
_scrollToBottomController.reverse();
|
||||
if (this.lastUserMessageIndex != lastUserMessageIndex) {
|
||||
// scroll the current message to the top
|
||||
await itemScrollController.scrollTo(
|
||||
index: lastUserMessageIndex,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
this.lastUserMessageIndex = lastUserMessageIndex;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Future<void> _handleScrollToBottom() async {
|
||||
isScrollingToBottom = true;
|
||||
|
||||
_listKey.currentState!.insertItem(
|
||||
0,
|
||||
duration: widget.insertAnimationDuration,
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
|
||||
await scrollToBottomController.reverse();
|
||||
|
||||
await itemScrollController.scrollTo(
|
||||
index: messages.length + 1,
|
||||
alignment: 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
// Used later to trigger scroll to end only for the last inserted message.
|
||||
// _lastInsertedMessageId = data.id;
|
||||
isScrollingToBottom = false;
|
||||
}
|
||||
|
||||
if (position == _oldList.length) {
|
||||
_scrollToTop(data);
|
||||
// _scrollToEnd(data);
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (isScrollingToBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get the max item
|
||||
final sortedItems = itemPositionsListener.itemPositions.value.toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
final maxItem = sortedItems.lastOrNull;
|
||||
|
||||
if (maxItem == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxItem.index > messages.length - 1 ||
|
||||
(maxItem.index == messages.length - 1 &&
|
||||
maxItem.itemTrailingEdge <= 1.01)) {
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomController.reverse();
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
final sortedItems = itemPositionsListener.itemPositions.value.toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
final minItem = sortedItems.firstOrNull;
|
||||
|
||||
if (minItem == null || minItem.index > 0 || minItem.itemLeadingEdge < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadPreviousMessagesDebounce.call(
|
||||
() {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _cacheMessageHeight(String messageId, double height) {
|
||||
heightManager.cacheHeight(messageId: messageId, height: height);
|
||||
}
|
||||
|
||||
void _clearMessageHeightCache() {
|
||||
heightManager.clearCache();
|
||||
}
|
||||
|
||||
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) {
|
||||
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,
|
||||
);
|
||||
// Clean up cached height for removed message
|
||||
heightManager.removeFromCache(messageId: data.id);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
update.when<void>(
|
||||
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
|
||||
remove: (pos, data) => _onRemoved(pos, data),
|
||||
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
|
||||
move: (_, __, ___) => throw UnimplementedError('unused'),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleLoadPreviousMessages() {
|
||||
if (widget.scrollController.offset >=
|
||||
widget.scrollController.position.maxScrollExtent) {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
}
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -207,12 +207,21 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
if (!UniversalPlatform.isMobile) return;
|
||||
final paletteBloc = context.read<CommandPaletteBloc?>(),
|
||||
paletteState = paletteBloc?.state;
|
||||
final isAskingAI = paletteState?.askAI ?? false;
|
||||
if (paletteBloc == null || paletteState == null) return;
|
||||
final isAskingAI = paletteState.askAI;
|
||||
if (!isAskingAI) return;
|
||||
final query = paletteState?.query ?? '';
|
||||
paletteBloc.add(CommandPaletteEvent.askedAI());
|
||||
final query = paletteState.query ?? '';
|
||||
if (query.isEmpty) return;
|
||||
onSubmitText(query);
|
||||
paletteBloc?.add(CommandPaletteEvent.askedAI());
|
||||
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 handleTextControllerChanged() {
|
||||
|
||||
+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);
|
||||
|
||||
Loaded 100 of 606 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user