Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
627b8ab43c | ||
|
|
84d55b5022 | ||
|
|
b53e20f746 | ||
|
|
cd06161dea | ||
|
|
776f70a0c7 | ||
|
|
1d1647d58d | ||
|
|
b3f94be33f | ||
|
|
ace398537b | ||
|
|
430e21aec2 | ||
|
|
0c1eb7306a | ||
|
|
db11886e5f | ||
|
|
0d13336b32 | ||
|
|
60ad397105 | ||
|
|
fca3189c97 | ||
|
|
e63f767926 | ||
|
|
8826e479eb | ||
|
|
8e4fe3d559 | ||
|
|
e2ee11e48a | ||
|
|
bdc0fa1f2a | ||
|
|
3836545682 | ||
|
|
02eb0e0b83 | ||
|
|
38c2937f64 | ||
|
|
06d5bc734b | ||
|
|
a521541cb7 | ||
|
|
7d61252e6a | ||
|
|
cb2b9ac537 | ||
|
|
af31f8cf6f | ||
|
|
87c1245a3f | ||
|
|
49d9417cac | ||
|
|
1f29235538 | ||
|
|
fd1e36b21a | ||
|
|
7db11c7cfd | ||
|
|
33888d583b | ||
|
|
3959cdba3a | ||
|
|
200b367e4c | ||
|
|
956d2dfd07 | ||
|
|
83e50d376e | ||
|
|
802a667907 | ||
|
|
e67e9cc647 | ||
|
|
07c4389f6a | ||
|
|
171c5634f2 | ||
|
|
d7bda10e6f | ||
|
|
20bff9003e | ||
|
|
6e0534400b | ||
|
|
17c116a53b | ||
|
|
27b769362b | ||
|
|
b8e7d57ee6 | ||
|
|
ed052c6792 | ||
|
|
5cd2c63320 | ||
|
|
15567a81e9 | ||
|
|
7ab68dcc2c | ||
|
|
30131fd9e4 | ||
|
|
ddcdd545d9 | ||
|
|
d25a399aba | ||
|
|
ee96a44fef | ||
|
|
dda3962249 | ||
|
|
c2743472bc | ||
|
|
e73fd56152 | ||
|
|
04a013f7ee | ||
|
|
b966e3ea07 | ||
|
|
9d53f758d4 |
No files matched your search
@@ -73,7 +73,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: |
|
||||
vcpkg integrate install
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build Windows app
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- { target: x86_64-apple-darwin, os: macos-12, extra-build-args: "" }
|
||||
- { target: x86_64-apple-darwin, os: macos-13, extra-build-args: "" }
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
uses: actions/checkout@v4
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build AppFlowy
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- name: Build AppFlowy
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install -y alien libnotify-dev
|
||||
source $HOME/.cargo/env
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
rustup target add ${{ matrix.job.target }}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Install prerequisites
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo install --force --locked duckscript_cli
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
- name: install cargo-make
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo make appflowy-tauri-deps-tools
|
||||
|
||||
- name: install frontend dependencies
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
- name: install cargo-make
|
||||
working-directory: frontend
|
||||
run: |
|
||||
cargo install --force cargo-make
|
||||
cargo install --force --locked cargo-make
|
||||
cargo make appflowy-tauri-deps-tools
|
||||
|
||||
- name: install frontend dependencies
|
||||
|
||||
+53
-9
@@ -1,17 +1,61 @@
|
||||
# Release Notes
|
||||
## Version 0.7.7 - 09/12/2024
|
||||
## Version 0.8.0 - 06/01/2025
|
||||
### Bug Fixes
|
||||
Fixed error displaying in the page style menu
|
||||
Fixed filter logic in the icon picker
|
||||
Fixed error displaying in the Favorite/Recent page
|
||||
Fixed the color picker displaying when tapping down
|
||||
Fixed icons not being supported in subpage blocks
|
||||
Fixed recent icon functionality in the space icon menu
|
||||
Fixed "Insert Below" not auto-scrolling the table
|
||||
Fixed a to-do item with an emoji automatically creating a soft break
|
||||
Fixed header row/column tap areas being too small
|
||||
Fixed simple table alignment not working for items that wrap
|
||||
Fixed web content reverting after removing the inline code format on desktop
|
||||
Fixed inability to make changes to a row or column in the table when opening a new tab
|
||||
Fixed changing the language to CKB-KU causing a gray screen on mobile
|
||||
|
||||
## Version 0.7.9 - 30/12/2024
|
||||
### New Features
|
||||
- Added support "set to page width" and "distribute columns evenly" for table block
|
||||
- Added support for reordering column/row in table block
|
||||
- Added support inserting multiple lines in table block
|
||||
- Revamped the mention page interactions in AI chat page
|
||||
- Improve the AI referenced sources UI in AI chat page
|
||||
- Meet AppFlowy Web (Lite): Use AppFlowy directly in your browser.
|
||||
- Create beautiful documents with 22 content types and markdown support
|
||||
- Use Quick Note to save anything you want to remember—like meeting notes, a grocery list, or to-dos
|
||||
- Invite members to your workspace for seamless collaboration
|
||||
- Create multiple public/private spaces to better organize your content
|
||||
- Simple Table is now available on Mobile, designed specifically for mobile devices.
|
||||
- Create and manage Simple Table blocks on Mobile with easy-to-use action menus.
|
||||
- Use the '+' button in the fixed toolbar to easily add a content block into a table cell on Mobile
|
||||
- Use '/' to insert a content block into a table cell on Desktop
|
||||
- Add pages as AI sources in AI chat, enabling you to ask questions about the selected sources
|
||||
- Add messages to an editable document while chatting with AI side by side
|
||||
- The new Emoji menu now includes Icons with a Recent section for quickly reusing emojis/icons
|
||||
- Drag a page from the sidebar into a document to easily mention the page without typing its title
|
||||
- Paste as plain text, a new option in the right-click paste menu
|
||||
### Bug Fixes
|
||||
- Fixed misalignment in numbered lists
|
||||
- Resolved several bugs in the emoji menu
|
||||
- Fixed a bug with checklist items
|
||||
|
||||
## Version 0.7.8 - 18/12/2024
|
||||
### New Features
|
||||
<img width="1068" alt="image" src="https://github.com/user-attachments/assets/cf8bd287-f370-4291-8638-76e2bbf4aaac" />
|
||||
|
||||
- Meet Simple Table 2.0:
|
||||
- Insert a list into a table cell
|
||||
- Insert images, quotes, callouts, and code blocks into a table cell
|
||||
- Drag to move rows or columns
|
||||
- Toggle header rows or columns on/off
|
||||
- Distribute columns evenly
|
||||
- Adjust to page width
|
||||
- Enjoy a new UI/UX for a seamless experience
|
||||
- Revamped mention page interactions in AI Chat
|
||||
- Improved AppFlowy AI service
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed an error when opening files in the database in local mode
|
||||
- Fixed arrow up/down navigation not working for selecting a language in code block
|
||||
- Fixed an issue where deleting multiple blocks using the drag button on the document page didn't work
|
||||
|
||||
- Fixed arrow up/down navigation not working for selecting a language in Code Block
|
||||
- Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work
|
||||
|
||||
## Version 0.7.7 - 09/12/2024
|
||||
### Bug Fixes
|
||||
- Fixed sidebar menu resize regression
|
||||
|
||||
@@ -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.7.8"
|
||||
APPFLOWY_VERSION = "0.8.0"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -53,7 +53,7 @@ android {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "io.appflowy.appflowy"
|
||||
minSdkVersion 29
|
||||
targetSdkVersion 34
|
||||
targetSdkVersion 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
multiDexEnabled true
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'data_migration/data_migration_test_runner.dart'
|
||||
as data_migration_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_move_page_test.dart' as sidebar_move_page_test;
|
||||
import 'sidebar/sidebar_rename_untitled_test.dart'
|
||||
as sidebar_rename_untitled_test;
|
||||
@@ -26,4 +27,5 @@ Future<void> main() async {
|
||||
// sidebar
|
||||
sidebar_move_page_test.main();
|
||||
sidebar_rename_untitled_test.main();
|
||||
sidebar_icon_test.main();
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart';
|
||||
import 'package:flowy_svg/flowy_svg.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../../shared/emoji.dart';
|
||||
import '../../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
testWidgets('Change slide bar space icon', (tester) async {
|
||||
await tester.initializeAppFlowy(
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
await tester.tapGoogleLoginInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
final emojiIconData = await tester.loadIcon();
|
||||
final firstIcon = IconsData.fromJson(jsonDecode(emojiIconData.emoji));
|
||||
|
||||
await tester.hoverOnWidget(
|
||||
find.byType(SidebarSpaceHeader),
|
||||
onHover: () async {
|
||||
final moreOption = find.byType(SpaceMorePopup);
|
||||
await tester.tapButton(moreOption);
|
||||
expect(find.byType(FlowyIconEmojiPicker), findsNothing);
|
||||
await tester.tapSvgButton(SpaceMoreActionType.changeIcon.leftIconSvg);
|
||||
expect(find.byType(FlowyIconEmojiPicker), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
final icons = find.byWidgetPredicate(
|
||||
(w) => w is FlowySvg && w.svgString == firstIcon.iconContent,
|
||||
);
|
||||
expect(icons, findsOneWidget);
|
||||
await tester.tapIcon(EmojiIconData.icon(firstIcon));
|
||||
|
||||
final spaceHeader = find.byType(SidebarSpaceHeader);
|
||||
final spaceIcon = find.descendant(
|
||||
of: spaceHeader,
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is FlowySvg && w.svgString == firstIcon.iconContent,
|
||||
),
|
||||
);
|
||||
expect(spaceIcon, findsOneWidget);
|
||||
});
|
||||
}
|
||||
+66
-6
@@ -5,6 +5,7 @@ import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -102,8 +103,7 @@ void main() {
|
||||
expect(memberCount, findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('only display one menu item in the workspace menu',
|
||||
(tester) async {
|
||||
testWidgets('workspace menu popover behavior test', (tester) async {
|
||||
// only run the test when the feature flag is on
|
||||
if (!FeatureFlag.collaborativeWorkspace.isOn) {
|
||||
return;
|
||||
@@ -128,6 +128,8 @@ void main() {
|
||||
final workspaceItem = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMenuItem && w.workspace.name == name,
|
||||
);
|
||||
|
||||
// the workspace menu shouldn't conflict with logout
|
||||
await tester.hoverOnWidget(
|
||||
workspaceItem,
|
||||
onHover: () async {
|
||||
@@ -136,15 +138,73 @@ void main() {
|
||||
);
|
||||
expect(moreButton, findsOneWidget);
|
||||
await tester.tapButton(moreButton);
|
||||
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
|
||||
|
||||
final logoutButton = find.byType(WorkspaceMoreButton);
|
||||
await tester.tapButton(logoutButton);
|
||||
expect(find.text(LocaleKeys.button_logout.tr()), findsOneWidget);
|
||||
expect(moreButton, findsNothing);
|
||||
|
||||
await tester.tapButton(moreButton);
|
||||
expect(find.text(LocaleKeys.button_logout.tr()), findsNothing);
|
||||
expect(moreButton, findsOneWidget);
|
||||
},
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// clicking on the more action button for the same workspace shouldn't do
|
||||
// anything
|
||||
await tester.openCollaborativeWorkspaceMenu();
|
||||
await tester.hoverOnWidget(
|
||||
workspaceItem,
|
||||
onHover: () async {
|
||||
final moreButton = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMoreActionList && w.workspace.name == name,
|
||||
);
|
||||
expect(moreButton, findsOneWidget);
|
||||
await tester.tapButton(moreButton);
|
||||
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
|
||||
|
||||
// click it again
|
||||
await tester.tapButton(moreButton);
|
||||
|
||||
// nothing should happen
|
||||
expect(
|
||||
find.text(LocaleKeys.button_rename.tr()),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
|
||||
},
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// clicking on the more button of another workspace should close the menu
|
||||
// for this one
|
||||
await tester.openCollaborativeWorkspaceMenu();
|
||||
final moreButton = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMoreActionList && w.workspace.name == name,
|
||||
);
|
||||
await tester.hoverOnWidget(
|
||||
workspaceItem,
|
||||
onHover: () async {
|
||||
expect(moreButton, findsOneWidget);
|
||||
await tester.tapButton(moreButton);
|
||||
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
final otherWorspaceItem = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMenuItem && w.workspace.name != name,
|
||||
);
|
||||
final otherMoreButton = find.byWidgetPredicate(
|
||||
(w) => w is WorkspaceMoreActionList && w.workspace.name != name,
|
||||
);
|
||||
await tester.hoverOnWidget(
|
||||
otherWorspaceItem,
|
||||
onHover: () async {
|
||||
expect(otherMoreButton, findsOneWidget);
|
||||
await tester.tapButton(otherMoreButton);
|
||||
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
|
||||
|
||||
expect(moreButton, findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+9
-1
@@ -1,4 +1,5 @@
|
||||
import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
|
||||
@@ -9,7 +10,14 @@ import '../../shared/database_test_op.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('calendar', () {
|
||||
testWidgets('update calendar layout', (tester) async {
|
||||
|
||||
+10
-3
@@ -1,12 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.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';
|
||||
|
||||
@@ -14,7 +14,14 @@ import '../../shared/database_test_op.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('grid edit field test:', () {
|
||||
testWidgets('rename existing field', (tester) async {
|
||||
|
||||
+17
-3
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_edi
|
||||
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
|
||||
import 'package:appflowy/plugins/document/document_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
@@ -21,7 +22,14 @@ import '../../shared/emoji.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('grid row detail page:', () {
|
||||
testWidgets('opens', (tester) async {
|
||||
@@ -386,11 +394,16 @@ void main() {
|
||||
isChecked: false,
|
||||
);
|
||||
tester.assertPhantomChecklistItemAtIndex(index: 1);
|
||||
tester.assertPhantomChecklistItemContent("");
|
||||
|
||||
await tester.enterText(find.byType(PhantomChecklistItem), 'task 2');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 500));
|
||||
await tester.hoverOnWidget(
|
||||
find.byType(ChecklistRowDetailCell),
|
||||
onHover: () async {
|
||||
await tester.tapButton(find.byType(ChecklistItemControl));
|
||||
},
|
||||
);
|
||||
|
||||
tester.assertChecklistTaskInEditor(
|
||||
index: 1,
|
||||
@@ -398,6 +411,7 @@ void main() {
|
||||
isChecked: false,
|
||||
);
|
||||
tester.assertPhantomChecklistItemAtIndex(index: 2);
|
||||
tester.assertPhantomChecklistItemContent("");
|
||||
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
+46
-4
@@ -1,7 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
@@ -11,6 +9,7 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -27,10 +26,13 @@ void main() {
|
||||
await tester.pasteContent(
|
||||
plainText: List.generate(lines, (index) => 'line $index').join('\n'),
|
||||
(editorState) {
|
||||
expect(editorState.document.root.children.length, 3);
|
||||
expect(editorState.document.root.children.length, 1);
|
||||
final text =
|
||||
editorState.document.root.children.first.delta!.toPlainText();
|
||||
final textLines = text.split('\n');
|
||||
for (var i = 0; i < lines; i++) {
|
||||
expect(
|
||||
editorState.getNodeAtPath([i])!.delta!.toPlainText(),
|
||||
textLines[i],
|
||||
'line $i',
|
||||
);
|
||||
}
|
||||
@@ -467,6 +469,44 @@ void main() {
|
||||
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
const testMarkdownText = '''
|
||||
# I'm h1
|
||||
## I'm h2
|
||||
### I'm h3
|
||||
#### I'm h4
|
||||
##### I'm h5
|
||||
###### I'm h6''';
|
||||
|
||||
testWidgets('paste markdowns', (tester) async {
|
||||
await tester.pasteContent(
|
||||
plainText: testMarkdownText,
|
||||
(editorState) {
|
||||
final children = editorState.document.root.children;
|
||||
expect(children.length, 6);
|
||||
for (int i = 1; i <= children.length; i++) {
|
||||
final text = children[i - 1].delta!.toPlainText();
|
||||
expect(text, 'I\'m h$i');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('paste markdowns as plain', (tester) async {
|
||||
await tester.pasteContent(
|
||||
plainText: testMarkdownText,
|
||||
pasteAsPlain: true,
|
||||
(editorState) {
|
||||
final children = editorState.document.root.children;
|
||||
expect(children.length, 6);
|
||||
for (int i = 1; i <= children.length; i++) {
|
||||
final text = children[i - 1].delta!.toPlainText();
|
||||
final expectText = '${'#' * i} I\'m h$i';
|
||||
expect(text, expectText);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
@@ -476,6 +516,7 @@ extension on WidgetTester {
|
||||
String? plainText,
|
||||
String? html,
|
||||
String? inAppJson,
|
||||
bool pasteAsPlain = false,
|
||||
(String, Uint8List?)? image,
|
||||
}) async {
|
||||
await initializeAppFlowy();
|
||||
@@ -502,6 +543,7 @@ extension on WidgetTester {
|
||||
await simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyV,
|
||||
isControlPressed: Platform.isLinux || Platform.isWindows,
|
||||
isShiftPressed: pasteAsPlain,
|
||||
isMetaPressed: Platform.isMacOS,
|
||||
);
|
||||
await pumpAndSettle();
|
||||
|
||||
+6
-7
@@ -37,12 +37,12 @@ void main() {
|
||||
|
||||
// set clipboard data
|
||||
final data = [
|
||||
"123456\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
||||
"1234567\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
||||
"12345678\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
|
||||
"123456\n\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
|
||||
"1234567\n\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
|
||||
"12345678\n\n",
|
||||
...List.generate(100, (_) => "${generateRandomString(50)}\n\n"),
|
||||
].join();
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
@@ -155,7 +155,6 @@ void main() {
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
|
||||
|
||||
},
|
||||
);
|
||||
}
|
||||
+43
-1
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -11,6 +13,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/emoji.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
// Test cases for the Document SubPageBlock that needs to be covered:
|
||||
@@ -37,7 +40,14 @@ import '../../shared/util.dart';
|
||||
const _defaultPageName = "";
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('Document SubPageBlock tests', () {
|
||||
testWidgets('Insert a new SubPageBlock from Slash menu items',
|
||||
@@ -498,6 +508,38 @@ void main() {
|
||||
|
||||
expect(find.text('Parent'), findsNWidgets(2));
|
||||
});
|
||||
|
||||
testWidgets('Displaying icon of subpage', (tester) async {
|
||||
const firstPage = 'FirstPage';
|
||||
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(name: firstPage);
|
||||
final icon = await tester.loadIcon();
|
||||
|
||||
/// create subpage
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_subPage_name.tr(),
|
||||
offset: 100,
|
||||
);
|
||||
|
||||
/// add icon
|
||||
await tester.editor.hoverOnCoverToolbar();
|
||||
await tester.editor.tapAddIconButton();
|
||||
await tester.tapIcon(icon);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.openPage(firstPage);
|
||||
|
||||
/// check if there is a icon in document
|
||||
final iconWidget = find.byWidgetPredicate((w) {
|
||||
if (w is! RawEmojiIconWidget) return false;
|
||||
final iconData = w.emoji.emoji;
|
||||
return iconData == icon.emoji;
|
||||
});
|
||||
expect(iconWidget, findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -1,5 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,7 +13,14 @@ import '../../shared/emoji.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('cover image:', () {
|
||||
testWidgets('document cover tests', (tester) async {
|
||||
@@ -147,7 +156,7 @@ void main() {
|
||||
tester.expectViewHasIcon(
|
||||
gettingStarted,
|
||||
ViewLayoutPB.Document,
|
||||
punch,
|
||||
EmojiIconData.emoji(punch),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
-7
@@ -138,13 +138,6 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('insert a jpg image from network', (tester) async {
|
||||
await testEmbedImage(
|
||||
tester,
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('insert an image from unsplash', (tester) async {
|
||||
await runWithNetworkImages(() async {
|
||||
await tester.initializeAppFlowy();
|
||||
|
||||
+15
-1
@@ -147,7 +147,21 @@ void main() {
|
||||
const text = 'Hello World';
|
||||
await tester.ime.insertText(text);
|
||||
|
||||
expect(find.textContaining(text, findRichText: true), findsOneWidget);
|
||||
final inlineText = find.textContaining(text, findRichText: true);
|
||||
expect(inlineText, findsOneWidget);
|
||||
|
||||
// the text should be in the same line with the math equation
|
||||
final inlineMathEquationPosition = tester.getRect(inlineMathEquation);
|
||||
final textPosition = tester.getRect(inlineText);
|
||||
// allow 5px difference
|
||||
expect(
|
||||
(textPosition.top - inlineMathEquationPosition.top).abs(),
|
||||
lessThan(5),
|
||||
);
|
||||
expect(
|
||||
(textPosition.bottom - inlineMathEquationPosition.bottom).abs(),
|
||||
lessThan(5),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+163
@@ -561,6 +561,169 @@ void main() {
|
||||
final node = editorState.document.nodeAtPath([0, 0, 0])!;
|
||||
expect(node.children.length, 1);
|
||||
});
|
||||
|
||||
testWidgets('using option menu to set table align', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(beforeAlign, TableAlign.left);
|
||||
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_optionAction_center.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(afterAlign, TableAlign.center);
|
||||
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_optionAction_right.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(afterAlign2, TableAlign.right);
|
||||
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_optionAction_left.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(afterAlign3, TableAlign.left);
|
||||
});
|
||||
|
||||
testWidgets('using option menu to set table align', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(beforeAlign, TableAlign.left);
|
||||
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_optionAction_center.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(afterAlign, TableAlign.center);
|
||||
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_optionAction_right.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(afterAlign2, TableAlign.right);
|
||||
|
||||
await tester.editor.hoverAndClickOptionMenuButton([0]);
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.text(
|
||||
LocaleKeys.document_plugins_optionAction_left.tr(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign;
|
||||
expect(afterAlign3, TableAlign.left);
|
||||
});
|
||||
|
||||
testWidgets('support slash menu in table', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: 'simple_table_test',
|
||||
);
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.insertTableInDocument();
|
||||
|
||||
final path = [0, 0, 0, 0];
|
||||
final selection = Selection.collapsed(Position(path: path));
|
||||
editorState.selection = selection;
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final paragraphItem = find.byWidgetPredicate((w) {
|
||||
return w is SelectionMenuItemWidget &&
|
||||
w.item.name == LocaleKeys.document_slashMenu_name_text.tr();
|
||||
});
|
||||
expect(paragraphItem, findsOneWidget);
|
||||
|
||||
await tester.tap(paragraphItem);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final paragraphNode = editorState.document.nodeAtPath(path)!;
|
||||
expect(paragraphNode.type, equals(ParagraphBlockKeys.type));
|
||||
});
|
||||
}
|
||||
|
||||
extension on WidgetTester {
|
||||
|
||||
+2
-1
@@ -67,6 +67,7 @@ void main() {
|
||||
|
||||
// open settings page to check the result
|
||||
await tester.tapButton(settingsButton);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 250));
|
||||
|
||||
// check the server type
|
||||
expect(
|
||||
@@ -82,7 +83,7 @@ void main() {
|
||||
// reset to appflowy cloud
|
||||
await tester.tapButton(
|
||||
findServerType(AuthenticatorType.appflowyCloudSelfHost),
|
||||
);
|
||||
);
|
||||
// change the server type to appflowy cloud
|
||||
await tester.tapButton(
|
||||
findServerType(AuthenticatorType.appflowyCloud),
|
||||
|
||||
+191
-62
@@ -1,83 +1,212 @@
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_field.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/base.dart';
|
||||
import '../../shared/common_operations.dart';
|
||||
import '../../shared/emoji.dart';
|
||||
import '../../shared/expectation.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final emoji = EmojiIconData.emoji('😁');
|
||||
|
||||
const emoji = '😁';
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
group('Icon:', () {
|
||||
testWidgets('Update page icon in sidebar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
testWidgets('Update page emoji in sidebar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// update its icon
|
||||
await tester.updatePageIconInSidebarByName(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
icon: emoji,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
|
||||
testWidgets('Update page icon in title bar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
// update its emoji
|
||||
await tester.updatePageIconInSidebarByName(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
icon: emoji,
|
||||
);
|
||||
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
testWidgets('Update page emoji in title bar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// update its icon
|
||||
await tester.updatePageIconInTitleBarByName(
|
||||
name: value.name,
|
||||
layout: value,
|
||||
icon: emoji,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
|
||||
tester.expectViewTitleHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
|
||||
// update its emoji
|
||||
await tester.updatePageIconInTitleBarByName(
|
||||
name: value.name,
|
||||
layout: value,
|
||||
icon: emoji,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
|
||||
tester.expectViewTitleHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Emoji Search Bar Get Focus', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
|
||||
await tester.openPage(
|
||||
value.name,
|
||||
layout: value,
|
||||
);
|
||||
final title = find.descendant(
|
||||
of: find.byType(ViewTitleBar),
|
||||
matching: find.text(value.name),
|
||||
);
|
||||
await tester.tapButton(title);
|
||||
await tester.tapButton(find.byType(EmojiPickerButton));
|
||||
|
||||
final emojiPicker = find.byType(FlowyEmojiPicker);
|
||||
expect(emojiPicker, findsOneWidget);
|
||||
final textField = find.descendant(
|
||||
of: emojiPicker,
|
||||
matching: find.byType(FlowyTextField),
|
||||
);
|
||||
expect(textField, findsOneWidget);
|
||||
final textFieldWidget =
|
||||
textField.evaluate().first.widget as FlowyTextField;
|
||||
assert(textFieldWidget.focusNode!.hasFocus);
|
||||
await tester.tapEmoji(emoji.emoji);
|
||||
await tester.pumpAndSettle();
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
emoji,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Update page icon in sidebar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
final iconData = await tester.loadIcon();
|
||||
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
|
||||
// update its icon
|
||||
await tester.updatePageIconInSidebarByName(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
icon: iconData,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
iconData,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Update page icon in title bar', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
final iconData = await tester.loadIcon();
|
||||
|
||||
// create document, board, grid and calendar views
|
||||
for (final value in ViewLayoutPB.values) {
|
||||
if (value == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await tester.createNewPageWithNameUnderParent(
|
||||
name: value.name,
|
||||
parentName: gettingStarted,
|
||||
layout: value,
|
||||
);
|
||||
|
||||
// update its icon
|
||||
await tester.updatePageIconInTitleBarByName(
|
||||
name: value.name,
|
||||
layout: value,
|
||||
icon: iconData,
|
||||
);
|
||||
|
||||
tester.expectViewHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
iconData,
|
||||
);
|
||||
|
||||
tester.expectViewTitleHasIcon(
|
||||
value.name,
|
||||
value,
|
||||
iconData,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
+15
-2
@@ -1,5 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -11,7 +13,14 @@ import '../../shared/emoji.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('Sidebar view item tests', () {
|
||||
testWidgets('Access view item context menu by right click', (tester) async {
|
||||
@@ -38,7 +47,11 @@ void main() {
|
||||
await tester.tapEmoji(emoji);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
|
||||
tester.expectViewHasIcon(
|
||||
gettingStarted,
|
||||
ViewLayoutPB.Document,
|
||||
EmojiIconData.emoji(emoji),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+24
-8
@@ -1,6 +1,5 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block/code_block_language_selector.dart';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -45,8 +44,10 @@ void main() {
|
||||
await onKey(tester, LogicalKeyboardKey.enter);
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final language =
|
||||
editorState.getNodeAtPath([0])!.attributes['language'].toString();
|
||||
String language = editorState
|
||||
.getNodeAtPath([0])!
|
||||
.attributes[CodeBlockKeys.language]
|
||||
.toString();
|
||||
expect(
|
||||
language.toLowerCase(),
|
||||
defaultCodeBlockSupportedLanguages.first.toLowerCase(),
|
||||
@@ -58,14 +59,29 @@ void main() {
|
||||
await onKey(tester, LogicalKeyboardKey.arrowUp);
|
||||
await onKey(tester, LogicalKeyboardKey.enter);
|
||||
|
||||
language = editorState
|
||||
.getNodeAtPath([0])!
|
||||
.attributes[CodeBlockKeys.language]
|
||||
.toString();
|
||||
expect(
|
||||
editorState
|
||||
.getNodeAtPath([0])!
|
||||
.attributes['language']
|
||||
.toString()
|
||||
.toLowerCase(),
|
||||
language.toLowerCase(),
|
||||
defaultCodeBlockSupportedLanguages.last.toLowerCase(),
|
||||
);
|
||||
|
||||
await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget));
|
||||
await tester.tapButtonWithName(language);
|
||||
tester.testTextInput.enterText("rust");
|
||||
await onKey(tester, LogicalKeyboardKey.delete);
|
||||
await onKey(tester, LogicalKeyboardKey.delete);
|
||||
await onKey(tester, LogicalKeyboardKey.arrowDown);
|
||||
tester.testTextInput.enterText("st");
|
||||
await onKey(tester, LogicalKeyboardKey.arrowDown);
|
||||
await onKey(tester, LogicalKeyboardKey.enter);
|
||||
language = editorState
|
||||
.getNodeAtPath([0])!
|
||||
.attributes[CodeBlockKeys.language]
|
||||
.toString();
|
||||
expect(language.toLowerCase(), 'rust');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'page_style_test.dart' as page_style_test;
|
||||
import 'plus_menu_test.dart' as plus_menu_test;
|
||||
import 'simple_table_test.dart' as simple_table_test;
|
||||
import 'title_test.dart' as title_test;
|
||||
|
||||
void main() {
|
||||
@@ -11,4 +12,5 @@ void main() {
|
||||
title_test.main();
|
||||
page_style_test.main();
|
||||
plus_menu_test.main();
|
||||
simple_table_test.main();
|
||||
}
|
||||
@@ -1,17 +1,30 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
|
||||
import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.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/emoji.dart';
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
RecentIcons.enable = false;
|
||||
});
|
||||
|
||||
tearDownAll(() {
|
||||
RecentIcons.enable = true;
|
||||
});
|
||||
|
||||
group('document page style:', () {
|
||||
double getCurrentEditorFontSize() {
|
||||
@@ -114,5 +127,37 @@ void main() {
|
||||
);
|
||||
expect(builtInCover, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('page style icon', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
|
||||
final createPageButton =
|
||||
find.byKey(BottomNavigationBarItemType.add.valueKey);
|
||||
await tester.tapButton(createPageButton);
|
||||
|
||||
/// toggle the preset button
|
||||
await tester.tapSvgButton(FlowySvgs.m_layout_s);
|
||||
|
||||
/// select document plugins emoji
|
||||
final pageStyleIcon = find.byType(PageStyleIcon);
|
||||
|
||||
/// there should be none of emoji
|
||||
final noneText = find.text(LocaleKeys.pageStyle_none.tr());
|
||||
expect(noneText, findsOneWidget);
|
||||
await tester.tapButton(pageStyleIcon);
|
||||
|
||||
/// select an emoji
|
||||
const emoji = '😄';
|
||||
await tester.tapEmoji(emoji);
|
||||
await tester.tapSvgButton(FlowySvgs.m_layout_s);
|
||||
expect(noneText, findsNothing);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: pageStyleIcon,
|
||||
matching: find.text(emoji),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('simple table:', () {
|
||||
testWidgets('''
|
||||
1. insert a simple table via + menu
|
||||
2. insert a row above the table
|
||||
3. insert a row below the table
|
||||
4. insert a column left to the table
|
||||
5. insert a column right to the table
|
||||
6. delete the first row
|
||||
7. delete the first column
|
||||
''', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
await tester.createNewDocumentOnMobile('simple table');
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
// focus on the editor
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final firstParagraphPath = [0, 0, 0, 0];
|
||||
|
||||
// open the plus menu and select the table block
|
||||
{
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_table.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.type, equals(SimpleTableBlockKeys.type));
|
||||
expect(table.rowLength, equals(2));
|
||||
expect(table.columnLength, equals(2));
|
||||
|
||||
// focus on the first cell
|
||||
|
||||
final selection = editorState.selection!;
|
||||
expect(selection.isCollapsed, isTrue);
|
||||
expect(selection.start.path, equals(firstParagraphPath));
|
||||
}
|
||||
|
||||
// insert left and insert right
|
||||
{
|
||||
// click the column menu button
|
||||
await tester.clickColumnMenuButton(0);
|
||||
|
||||
// insert left, insert right
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(),
|
||||
),
|
||||
);
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertRight
|
||||
.tr(),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.cancelTableActionMenu();
|
||||
|
||||
// check the table is updated
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.type, equals(SimpleTableBlockKeys.type));
|
||||
expect(table.rowLength, equals(2));
|
||||
expect(table.columnLength, equals(4));
|
||||
}
|
||||
|
||||
// insert above and insert below
|
||||
{
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the row menu button
|
||||
await tester.clickRowMenuButton(0);
|
||||
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove
|
||||
.tr(),
|
||||
),
|
||||
);
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow
|
||||
.tr(),
|
||||
),
|
||||
);
|
||||
await tester.cancelTableActionMenu();
|
||||
|
||||
// check the table is updated
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.rowLength, equals(4));
|
||||
expect(table.columnLength, equals(4));
|
||||
}
|
||||
|
||||
// delete the first row
|
||||
{
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// delete the first row
|
||||
await tester.clickRowMenuButton(0);
|
||||
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
|
||||
await tester.cancelTableActionMenu();
|
||||
|
||||
// check the table is updated
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.rowLength, equals(3));
|
||||
expect(table.columnLength, equals(4));
|
||||
}
|
||||
|
||||
// delete the first column
|
||||
{
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.clickColumnMenuButton(0);
|
||||
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
|
||||
await tester.cancelTableActionMenu();
|
||||
|
||||
// check the table is updated
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.rowLength, equals(3));
|
||||
expect(table.columnLength, equals(3));
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('''
|
||||
1. insert a simple table via + menu
|
||||
2. enable header column
|
||||
3. enable header row
|
||||
4. set to page width
|
||||
5. distribute columns evenly
|
||||
''', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
await tester.createNewDocumentOnMobile('simple table');
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
// focus on the editor
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final firstParagraphPath = [0, 0, 0, 0];
|
||||
|
||||
// open the plus menu and select the table block
|
||||
{
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_table.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.type, equals(SimpleTableBlockKeys.type));
|
||||
expect(table.rowLength, equals(2));
|
||||
expect(table.columnLength, equals(2));
|
||||
|
||||
// focus on the first cell
|
||||
|
||||
final selection = editorState.selection!;
|
||||
expect(selection.isCollapsed, isTrue);
|
||||
expect(selection.start.path, equals(firstParagraphPath));
|
||||
}
|
||||
|
||||
// enable header column
|
||||
{
|
||||
// click the column menu button
|
||||
await tester.clickColumnMenuButton(0);
|
||||
|
||||
// enable header column
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn
|
||||
.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// enable header row
|
||||
{
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the row menu button
|
||||
await tester.clickRowMenuButton(0);
|
||||
|
||||
// enable header column
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// check the table is updated
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.type, equals(SimpleTableBlockKeys.type));
|
||||
expect(table.isHeaderColumnEnabled, isTrue);
|
||||
expect(table.isHeaderRowEnabled, isTrue);
|
||||
|
||||
// disable header column
|
||||
{
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the row menu button
|
||||
await tester.clickColumnMenuButton(0);
|
||||
|
||||
final toggleButton = find.descendant(
|
||||
of: find.byType(SimpleTableHeaderActionButton),
|
||||
matching: find.byType(CupertinoSwitch),
|
||||
);
|
||||
await tester.tapButton(toggleButton);
|
||||
}
|
||||
|
||||
// enable header row
|
||||
{
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the row menu button
|
||||
await tester.clickRowMenuButton(0);
|
||||
|
||||
// enable header column
|
||||
final toggleButton = find.descendant(
|
||||
of: find.byType(SimpleTableHeaderActionButton),
|
||||
matching: find.byType(CupertinoSwitch),
|
||||
);
|
||||
await tester.tapButton(toggleButton);
|
||||
}
|
||||
|
||||
// check the table is updated
|
||||
expect(table.isHeaderColumnEnabled, isFalse);
|
||||
expect(table.isHeaderRowEnabled, isFalse);
|
||||
|
||||
// set to page width
|
||||
{
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
final beforeWidth = table.width;
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the row menu button
|
||||
await tester.clickRowMenuButton(0);
|
||||
|
||||
// enable header column
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth
|
||||
.tr(),
|
||||
),
|
||||
);
|
||||
|
||||
// check the table is updated
|
||||
expect(table.width, greaterThan(beforeWidth));
|
||||
}
|
||||
|
||||
// distribute columns evenly
|
||||
{
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
final beforeWidth = table.width;
|
||||
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the column menu button
|
||||
await tester.clickColumnMenuButton(0);
|
||||
|
||||
// distribute columns evenly
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys
|
||||
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
|
||||
.tr(),
|
||||
),
|
||||
);
|
||||
|
||||
// check the table is updated
|
||||
expect(table.width, equals(beforeWidth));
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('''
|
||||
1. insert a simple table via + menu
|
||||
2. bold
|
||||
3. clear content
|
||||
''', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
await tester.createNewDocumentOnMobile('simple table');
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
// focus on the editor
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final firstParagraphPath = [0, 0, 0, 0];
|
||||
|
||||
// open the plus menu and select the table block
|
||||
{
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_table.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.type, equals(SimpleTableBlockKeys.type));
|
||||
expect(table.rowLength, equals(2));
|
||||
expect(table.columnLength, equals(2));
|
||||
|
||||
// focus on the first cell
|
||||
|
||||
final selection = editorState.selection!;
|
||||
expect(selection.isCollapsed, isTrue);
|
||||
expect(selection.start.path, equals(firstParagraphPath));
|
||||
}
|
||||
|
||||
await tester.ime.insertText('Hello');
|
||||
|
||||
// enable bold
|
||||
{
|
||||
// click the column menu button
|
||||
await tester.clickColumnMenuButton(0);
|
||||
|
||||
// enable bold
|
||||
await tester.clickSimpleTableBoldContentAction();
|
||||
await tester.cancelTableActionMenu();
|
||||
|
||||
// check the first cell is bold
|
||||
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
|
||||
expect(paragraph.isInBoldColumn, isTrue);
|
||||
}
|
||||
|
||||
// clear content
|
||||
{
|
||||
// focus on the first cell
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: firstParagraphPath)),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// click the column menu button
|
||||
await tester.clickColumnMenuButton(0);
|
||||
|
||||
final clearContents = find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents
|
||||
.tr(),
|
||||
);
|
||||
|
||||
// clear content
|
||||
final scrollable = find.descendant(
|
||||
of: find.byType(SimpleTableBottomSheet),
|
||||
matching: find.byType(Scrollable),
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
clearContents,
|
||||
100,
|
||||
scrollable: scrollable,
|
||||
);
|
||||
await tester.tapButton(clearContents);
|
||||
await tester.cancelTableActionMenu();
|
||||
|
||||
// check the first cell is empty
|
||||
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
|
||||
expect(paragraph.delta!, isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('''
|
||||
1. insert a simple table via + menu
|
||||
2. insert a heading block in table cell
|
||||
''', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
await tester.createNewDocumentOnMobile('simple table');
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
// focus on the editor
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: [0])),
|
||||
reason: SelectionUpdateReason.uiEvent,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final firstParagraphPath = [0, 0, 0, 0];
|
||||
|
||||
// open the plus menu and select the table block
|
||||
{
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.document_slashMenu_name_table.tr(),
|
||||
);
|
||||
|
||||
// check the block is inserted
|
||||
final table = editorState.getNodeAtPath([0])!;
|
||||
expect(table.type, equals(SimpleTableBlockKeys.type));
|
||||
expect(table.rowLength, equals(2));
|
||||
expect(table.columnLength, equals(2));
|
||||
|
||||
// focus on the first cell
|
||||
|
||||
final selection = editorState.selection!;
|
||||
expect(selection.isCollapsed, isTrue);
|
||||
expect(selection.start.path, equals(firstParagraphPath));
|
||||
}
|
||||
|
||||
// open the plus menu and select the heading block
|
||||
{
|
||||
await tester.openPlusMenuAndClickButton(
|
||||
LocaleKeys.editor_heading1.tr(),
|
||||
);
|
||||
|
||||
// check the heading block is inserted
|
||||
final heading = editorState.getNodeAtPath([0, 0, 0, 0])!;
|
||||
expect(heading.type, equals(HeadingBlockKeys.type));
|
||||
expect(heading.level, equals(1));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -175,6 +175,33 @@ extension AppFlowyTestBase on WidgetTester {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> tapDown(
|
||||
Finder finder, {
|
||||
int? pointer,
|
||||
int buttons = kPrimaryButton,
|
||||
PointerDeviceKind kind = PointerDeviceKind.touch,
|
||||
bool pumpAndSettle = true,
|
||||
int milliseconds = 500,
|
||||
}) async {
|
||||
final location = getCenter(finder);
|
||||
final TestGesture gesture = await startGesture(
|
||||
location,
|
||||
pointer: pointer,
|
||||
buttons: buttons,
|
||||
kind: kind,
|
||||
);
|
||||
await gesture.cancel();
|
||||
await gesture.down(location);
|
||||
await gesture.cancel();
|
||||
if (pumpAndSettle) {
|
||||
await this.pumpAndSettle(
|
||||
Duration(milliseconds: milliseconds),
|
||||
EnginePhase.sendSemanticsUpdate,
|
||||
const Duration(seconds: 15),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> tapButtonWithName(
|
||||
String tr, {
|
||||
int milliseconds = 500,
|
||||
|
||||
@@ -8,8 +8,12 @@ import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
|
||||
import 'package:appflowy/plugins/shared/share/share_button.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/presentation/screens/screens.dart';
|
||||
@@ -20,6 +24,7 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
|
||||
@@ -608,7 +613,7 @@ extension CommonOperations on WidgetTester {
|
||||
required String name,
|
||||
required String parentName,
|
||||
required ViewLayoutPB layout,
|
||||
required String icon,
|
||||
required EmojiIconData icon,
|
||||
}) async {
|
||||
final iconButton = find.descendant(
|
||||
of: findPageName(
|
||||
@@ -620,7 +625,11 @@ extension CommonOperations on WidgetTester {
|
||||
find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()),
|
||||
);
|
||||
await tapButton(iconButton);
|
||||
await tapEmoji(icon);
|
||||
if (icon.type == FlowyIconType.emoji) {
|
||||
await tapEmoji(icon.emoji);
|
||||
} else if (icon.type == FlowyIconType.icon) {
|
||||
await tapIcon(icon);
|
||||
}
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
@@ -628,7 +637,7 @@ extension CommonOperations on WidgetTester {
|
||||
Future<void> updatePageIconInTitleBarByName({
|
||||
required String name,
|
||||
required ViewLayoutPB layout,
|
||||
required String icon,
|
||||
required EmojiIconData icon,
|
||||
}) async {
|
||||
await openPage(
|
||||
name,
|
||||
@@ -640,7 +649,11 @@ extension CommonOperations on WidgetTester {
|
||||
);
|
||||
await tapButton(title);
|
||||
await tapButton(find.byType(EmojiPickerButton));
|
||||
await tapEmoji(icon);
|
||||
if (icon.type == FlowyIconType.emoji) {
|
||||
await tapEmoji(icon.emoji);
|
||||
} else if (icon.type == FlowyIconType.icon) {
|
||||
await tapIcon(icon);
|
||||
}
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
@@ -838,6 +851,71 @@ extension CommonOperations on WidgetTester {
|
||||
await tapButton(toggleHeading1);
|
||||
await pumpUntilNotFound(addMenuItem);
|
||||
}
|
||||
|
||||
/// Click the column menu button in the simple table
|
||||
Future<void> clickColumnMenuButton(int index) async {
|
||||
final columnMenuButton = find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is SimpleTableMobileReorderButton &&
|
||||
w.index == index &&
|
||||
w.type == SimpleTableMoreActionType.column,
|
||||
);
|
||||
await tapButton(columnMenuButton);
|
||||
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
|
||||
}
|
||||
|
||||
/// Click the row menu button in the simple table
|
||||
Future<void> clickRowMenuButton(int index) async {
|
||||
final rowMenuButton = find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is SimpleTableMobileReorderButton &&
|
||||
w.index == index &&
|
||||
w.type == SimpleTableMoreActionType.row,
|
||||
);
|
||||
await tapButton(rowMenuButton);
|
||||
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
|
||||
}
|
||||
|
||||
/// Click the SimpleTableQuickAction
|
||||
Future<void> clickSimpleTableQuickAction(SimpleTableMoreAction action) async {
|
||||
final button = find.byWidgetPredicate(
|
||||
(widget) => widget is SimpleTableQuickAction && widget.type == action,
|
||||
);
|
||||
await tapButton(button);
|
||||
}
|
||||
|
||||
/// Click the SimpleTableContentAction
|
||||
Future<void> clickSimpleTableBoldContentAction() async {
|
||||
final button = find.byType(SimpleTableContentBoldAction);
|
||||
await tapButton(button);
|
||||
}
|
||||
|
||||
/// Cancel the table action menu
|
||||
Future<void> cancelTableActionMenu() async {
|
||||
final finder = find.byType(SimpleTableCellBottomSheet);
|
||||
if (finder.evaluate().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tapAt(Offset.zero);
|
||||
await pumpUntilNotFound(finder);
|
||||
}
|
||||
|
||||
/// load icon list and return the first one
|
||||
Future<EmojiIconData> loadIcon() async {
|
||||
await loadIconGroups();
|
||||
final groups = kIconGroups!;
|
||||
final firstGroup = groups.first;
|
||||
final firstIcon = firstGroup.icons.first;
|
||||
return EmojiIconData.icon(
|
||||
IconsData(
|
||||
firstGroup.name,
|
||||
firstIcon.content,
|
||||
firstIcon.name,
|
||||
builtInSpaceColors.first,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsFinder on CommonFinders {
|
||||
|
||||
@@ -567,6 +567,12 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
expect(phantom is PhantomChecklistItem, true);
|
||||
}
|
||||
|
||||
void assertPhantomChecklistItemContent(String content) {
|
||||
final phantom = find.byType(PhantomChecklistItem);
|
||||
final text = find.text(content);
|
||||
expect(find.descendant(of: phantom, matching: text), findsOneWidget);
|
||||
}
|
||||
|
||||
Future<void> openFirstRowDetailPage() async {
|
||||
await hoverOnFirstRowOfGrid();
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
|
||||
import 'package:flowy_svg/flowy_svg.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -11,4 +20,48 @@ extension EmojiTestExtension on WidgetTester {
|
||||
);
|
||||
await tapButton(emojiWidget);
|
||||
}
|
||||
|
||||
Future<void> tapIcon(EmojiIconData icon) async {
|
||||
final iconsData = IconsData.fromJson(jsonDecode(icon.emoji));
|
||||
final pickTab = find.byType(PickerTab);
|
||||
expect(pickTab, findsOneWidget);
|
||||
await pumpAndSettle();
|
||||
final iconTab = find.descendant(
|
||||
of: pickTab,
|
||||
matching: find.text(PickerTabType.icon.tr),
|
||||
);
|
||||
expect(iconTab, findsOneWidget);
|
||||
await tapButton(iconTab);
|
||||
final selectedSvg = find.descendant(
|
||||
of: find.byType(FlowyIconPicker),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
|
||||
),
|
||||
);
|
||||
expect(find.byType(IconColorPicker), findsNothing);
|
||||
|
||||
/// test for tapping down, it should not display the ColorPicker unless tapping up
|
||||
await tapDown(selectedSvg);
|
||||
expect(find.byType(IconColorPicker), findsNothing);
|
||||
|
||||
await tapButton(selectedSvg);
|
||||
final colorPicker = find.byType(IconColorPicker);
|
||||
expect(colorPicker, findsOneWidget);
|
||||
final selectedColor = find.descendant(
|
||||
of: colorPicker,
|
||||
matching: find.byWidgetPredicate((w) {
|
||||
if (w is Container) {
|
||||
final d = w.decoration;
|
||||
if (d is ShapeDecoration) {
|
||||
if (d.color ==
|
||||
Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
await tapButton(selectedColor);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
|
||||
import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
@@ -5,6 +7,8 @@ import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
@@ -14,6 +18,7 @@ import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_svg/flowy_svg.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -117,7 +122,7 @@ extension Expectation on WidgetTester {
|
||||
return;
|
||||
}
|
||||
final iconWidget = find.byWidgetPredicate(
|
||||
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
|
||||
(widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji,
|
||||
);
|
||||
expect(iconWidget, findsOneWidget);
|
||||
}
|
||||
@@ -223,24 +228,52 @@ extension Expectation on WidgetTester {
|
||||
);
|
||||
}
|
||||
|
||||
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
|
||||
void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) {
|
||||
final pageName = findPageName(
|
||||
name,
|
||||
layout: layout,
|
||||
);
|
||||
final icon = find.descendant(
|
||||
of: pageName,
|
||||
matching: find.text(emoji),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
final type = data.type;
|
||||
if (type == FlowyIconType.emoji) {
|
||||
final icon = find.descendant(
|
||||
of: pageName,
|
||||
matching: find.text(data.emoji),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
} else if (type == FlowyIconType.icon) {
|
||||
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
|
||||
final icon = find.descendant(
|
||||
of: pageName,
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
|
||||
),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
}
|
||||
}
|
||||
|
||||
void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) {
|
||||
final icon = find.descendant(
|
||||
of: find.byType(ViewTitleBar),
|
||||
matching: find.text(emoji),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
void expectViewTitleHasIcon(
|
||||
String name,
|
||||
ViewLayoutPB layout,
|
||||
EmojiIconData data,
|
||||
) {
|
||||
final type = data.type;
|
||||
if (type == FlowyIconType.emoji) {
|
||||
final icon = find.descendant(
|
||||
of: find.byType(ViewTitleBar),
|
||||
matching: find.text(data.emoji),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
} else if (type == FlowyIconType.icon) {
|
||||
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
|
||||
final icon = find.descendant(
|
||||
of: find.byType(ViewTitleBar),
|
||||
matching: find.byWidgetPredicate(
|
||||
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
|
||||
),
|
||||
);
|
||||
expect(icon, findsOneWidget);
|
||||
}
|
||||
}
|
||||
|
||||
void expectSelectedReminder(ReminderOption option) {
|
||||
|
||||
@@ -88,6 +88,8 @@ PODS:
|
||||
- Toast (4.0.0)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
@@ -112,6 +114,7 @@ DEPENDENCIES:
|
||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/super_native_extensions/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
|
||||
@@ -185,21 +190,22 @@ SPEC CHECKSUMS:
|
||||
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
|
||||
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -109,4 +109,9 @@ class KVKeys {
|
||||
///
|
||||
/// The value is a boolean string
|
||||
static const String hasUpgradedSpace = 'hasUpgradedSpace060';
|
||||
|
||||
/// The key for saving the recent icons
|
||||
///
|
||||
/// The value is a json string of [RecentIcons]
|
||||
static const String recentIcons = 'kRecentIcons';
|
||||
}
|
||||
+7
-6
@@ -6,7 +6,6 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.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:collection/collection.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -26,11 +25,13 @@ class DocumentPageStyleBloc
|
||||
if (view.id.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final layoutObject =
|
||||
await ViewBackendService.getView(view.id).fold(
|
||||
(s) => jsonDecode(s.extra),
|
||||
(f) => {},
|
||||
);
|
||||
Map layoutObject = {};
|
||||
final data = await ViewBackendService.getView(view.id);
|
||||
data.onSuccess((s) {
|
||||
if (s.extra.isNotEmpty) {
|
||||
layoutObject = jsonDecode(s.extra);
|
||||
}
|
||||
});
|
||||
final fontLayout = _getSelectedFontLayout(layoutObject);
|
||||
final lineHeightLayout = _getSelectedLineHeightLayout(
|
||||
layoutObject,
|
||||
|
||||
@@ -7,6 +7,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
|
||||
part 'recent_view_bloc.freezed.dart';
|
||||
|
||||
class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
@@ -39,7 +41,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
add(
|
||||
RecentViewEvent.updateNameOrIcon(
|
||||
view.name,
|
||||
view.icon.value,
|
||||
view.icon.toEmojiIconData(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -61,7 +63,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
name: view.name,
|
||||
icon: view.icon.value,
|
||||
icon: view.icon.toEmojiIconData(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -72,7 +74,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
name: view.name,
|
||||
icon: view.icon.value,
|
||||
icon: view.icon.toEmojiIconData(),
|
||||
coverTypeV2: cover.type,
|
||||
coverValue: cover.value,
|
||||
),
|
||||
@@ -82,7 +84,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
name: view.name,
|
||||
icon: view.icon.value,
|
||||
icon: view.icon.toEmojiIconData(),
|
||||
coverTypeV1: coverTypeV1,
|
||||
coverValue: coverValue,
|
||||
),
|
||||
@@ -135,14 +137,17 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
|
||||
@freezed
|
||||
class RecentViewEvent with _$RecentViewEvent {
|
||||
const factory RecentViewEvent.initial() = Initial;
|
||||
|
||||
const factory RecentViewEvent.updateCover(
|
||||
CoverType coverTypeV1, // for the version under 0.5.5, including 0.5.5
|
||||
CoverType coverTypeV1,
|
||||
// for the version under 0.5.5, including 0.5.5
|
||||
PageStyleCoverImageType? coverTypeV2, // for the version above 0.5.5
|
||||
String? coverValue,
|
||||
) = UpdateCover;
|
||||
|
||||
const factory RecentViewEvent.updateNameOrIcon(
|
||||
String name,
|
||||
String icon,
|
||||
EmojiIconData icon,
|
||||
) = UpdateNameOrIcon;
|
||||
}
|
||||
|
||||
@@ -150,12 +155,12 @@ class RecentViewEvent with _$RecentViewEvent {
|
||||
class RecentViewState with _$RecentViewState {
|
||||
const factory RecentViewState({
|
||||
required String name,
|
||||
required String icon,
|
||||
required EmojiIconData icon,
|
||||
@Default(CoverType.none) CoverType coverTypeV1,
|
||||
PageStyleCoverImageType? coverTypeV2,
|
||||
@Default(null) String? coverValue,
|
||||
}) = _RecentViewState;
|
||||
|
||||
factory RecentViewState.initial() =>
|
||||
const RecentViewState(name: '', icon: '');
|
||||
RecentViewState(name: '', icon: EmojiIconData.none());
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/feature_flags.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
@@ -105,6 +108,12 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
create: (_) =>
|
||||
ShareBloc(view: view)..add(const ShareEvent.initial()),
|
||||
),
|
||||
if (state.userProfilePB != null)
|
||||
BlocProvider(
|
||||
create: (_) =>
|
||||
UserWorkspaceBloc(userProfile: state.userProfilePB!)
|
||||
..add(const UserWorkspaceEvent.initial()),
|
||||
),
|
||||
if (view.layout.isDocumentView)
|
||||
BlocProvider(
|
||||
create: (_) => DocumentPageStyleBloc(view: view)
|
||||
@@ -255,15 +264,14 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context, ViewPB? view) {
|
||||
final icon = view?.icon.value;
|
||||
final icon = view?.icon;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null && icon.isNotEmpty) ...[
|
||||
FlowyText.emoji(
|
||||
icon,
|
||||
fontSize: 15.0,
|
||||
figmaLineHeight: 18.0,
|
||||
if (icon != null && icon.value.isNotEmpty) ...[
|
||||
RawEmojiIconWidget(
|
||||
emoji: icon.toEmojiIconData(),
|
||||
emojiSize: 15,
|
||||
),
|
||||
const HSpace(4),
|
||||
],
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ class BottomSheetBackButton extends StatelessWidget {
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.m_app_bar_back_s,
|
||||
FlowySvgs.m_bottom_sheet_back_s,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+2
-1
@@ -138,7 +138,6 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
),
|
||||
_divider(),
|
||||
..._buildPublishActions(context),
|
||||
_divider(),
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.button_delete.tr(),
|
||||
textColor: Theme.of(context).colorScheme.error,
|
||||
@@ -191,6 +190,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
MobileViewBottomSheetBodyAction.unpublish,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
@@ -201,6 +201,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
MobileViewBottomSheetBodyAction.publish,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+29
-13
@@ -57,6 +57,7 @@ Future<T?> showMobileBottomSheet<T>(
|
||||
double maxChildSize = 0.8,
|
||||
double initialChildSize = 0.51,
|
||||
double bottomSheetPadding = 0,
|
||||
bool enablePadding = true,
|
||||
}) async {
|
||||
assert(
|
||||
showHeader ||
|
||||
@@ -172,14 +173,18 @@ Future<T?> showMobileBottomSheet<T>(
|
||||
}
|
||||
|
||||
// ----- content area -----
|
||||
// add content padding and extra bottom padding
|
||||
children.add(
|
||||
Padding(
|
||||
padding:
|
||||
padding + EdgeInsets.only(bottom: context.bottomSheetPadding()),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
if (enablePadding) {
|
||||
// add content padding and extra bottom padding
|
||||
children.add(
|
||||
Padding(
|
||||
padding:
|
||||
padding + EdgeInsets.only(bottom: context.bottomSheetPadding()),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.add(child);
|
||||
}
|
||||
// ----- content area -----
|
||||
|
||||
if (children.length == 1) {
|
||||
@@ -211,14 +216,21 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
required this.showDoneButton,
|
||||
this.onRemove,
|
||||
this.onDone,
|
||||
this.onBack,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
final bool showBackButton;
|
||||
final bool showCloseButton;
|
||||
final bool showRemoveButton;
|
||||
final String title;
|
||||
final bool showDoneButton;
|
||||
|
||||
final VoidCallback? onRemove;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
final void Function(BuildContext context)? onDone;
|
||||
|
||||
@override
|
||||
@@ -230,14 +242,18 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBackButton)
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BottomSheetBackButton(),
|
||||
child: BottomSheetBackButton(
|
||||
onTap: onBack,
|
||||
),
|
||||
),
|
||||
if (showCloseButton)
|
||||
const Align(
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: BottomSheetCloseButton(),
|
||||
child: BottomSheetCloseButton(
|
||||
onTap: onClose,
|
||||
),
|
||||
),
|
||||
if (showRemoveButton)
|
||||
Align(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/built_in_svgs.dart';
|
||||
import 'package:appflowy/workspace/application/user/settings_user_bloc.dart';
|
||||
@@ -228,12 +229,13 @@ class _UserIcon extends StatelessWidget {
|
||||
fontSize: 26,
|
||||
),
|
||||
onTap: () async {
|
||||
final icon = await context.push<EmojiPickerResult>(
|
||||
final icon = await context.push<EmojiIconData>(
|
||||
Uri(
|
||||
path: MobileEmojiPickerScreen.routeName,
|
||||
queryParameters: {
|
||||
MobileEmojiPickerScreen.pageTitle:
|
||||
LocaleKeys.titleBar_userIcon.tr(),
|
||||
MobileEmojiPickerScreen.selectTabs: [PickerTabType.emoji.name],
|
||||
},
|
||||
).toString(),
|
||||
);
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ class MobileRecentView extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: state.icon.isNotEmpty
|
||||
? EmojiText(
|
||||
emoji: state.icon,
|
||||
emoji: state.icon.emoji,
|
||||
fontSize: 30.0,
|
||||
)
|
||||
: SizedBox.square(
|
||||
|
||||
+13
-13
@@ -7,6 +7,7 @@ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.
|
||||
import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.dart';
|
||||
import 'package:appflowy/shared/flowy_gradient_colors.dart';
|
||||
@@ -26,7 +27,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:time/time.dart';
|
||||
@@ -118,7 +118,7 @@ class MobileViewPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) {
|
||||
final supportAvatar = isURL(state.icon);
|
||||
final supportAvatar = isURL(state.icon.emoji);
|
||||
if (!supportAvatar) {
|
||||
return _buildLastViewed(context);
|
||||
}
|
||||
@@ -173,23 +173,23 @@ class MobileViewPage extends StatelessWidget {
|
||||
Widget _buildTitle(BuildContext context, RecentViewState state) {
|
||||
final name = state.name;
|
||||
final icon = state.icon;
|
||||
final fontFamily = Platform.isAndroid || Platform.isLinux
|
||||
? GoogleFonts.notoColorEmoji().fontFamily
|
||||
: null;
|
||||
return RichText(
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: icon,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 17.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: fontFamily,
|
||||
if (icon.isNotEmpty) ...[
|
||||
WidgetSpan(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
child: EmojiIconWidget(
|
||||
emoji: icon,
|
||||
emojiSize: 18.0,
|
||||
),
|
||||
),
|
||||
if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
|
||||
),
|
||||
),
|
||||
const WidgetSpan(child: HSpace(8.0)),
|
||||
],
|
||||
TextSpan(
|
||||
text: name,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
const _kNotificationIconHeight = 36.0;
|
||||
|
||||
@@ -264,7 +265,11 @@ class NotificationDocumentContent extends StatelessWidget {
|
||||
styleCustomizer: styleCustomizer,
|
||||
// the editor is not editable in the chat
|
||||
editable: false,
|
||||
customHeadingPadding: EdgeInsets.zero,
|
||||
customHeadingPadding: UniversalPlatform.isDesktop
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.symmetric(
|
||||
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
|
||||
),
|
||||
);
|
||||
|
||||
return IgnorePointer(
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
@@ -121,6 +123,7 @@ class InnerMobileViewItem extends StatelessWidget {
|
||||
final bool isDraggable;
|
||||
final bool isExpanded;
|
||||
final bool isFirstChild;
|
||||
|
||||
// identify if the view item is rendered as feedback widget inside DraggableItem
|
||||
final bool isFeedback;
|
||||
|
||||
@@ -230,6 +233,7 @@ class SingleMobileInnerViewItem extends StatefulWidget {
|
||||
final ViewPB view;
|
||||
final ViewPB? parentView;
|
||||
final bool isExpanded;
|
||||
|
||||
// identify if the view item is rendered as feedback widget inside DraggableItem
|
||||
final bool isFeedback;
|
||||
|
||||
@@ -297,11 +301,9 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
|
||||
|
||||
Widget _buildViewIcon() {
|
||||
final icon = widget.view.icon.value.isNotEmpty
|
||||
? FlowyText.emoji(
|
||||
widget.view.icon.value,
|
||||
fontSize: Platform.isAndroid ? 16.0 : 18.0,
|
||||
figmaLineHeight: 20.0,
|
||||
optimizeEmojiAlign: true,
|
||||
? EmojiIconWidget(
|
||||
emoji: widget.view.icon.toEmojiIconData(),
|
||||
emojiSize: Platform.isAndroid ? 16.0 : 18.0,
|
||||
)
|
||||
: Opacity(
|
||||
opacity: 0.7,
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ class MobileQuickActionButton extends StatelessWidget {
|
||||
enable ? null : const WidgetStatePropertyAll(Colors.transparent),
|
||||
splashColor: Colors.transparent,
|
||||
child: Container(
|
||||
height: 52,
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -37,8 +37,13 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
// Only user enable chat with file and the plugin is already running
|
||||
final supportChatWithFile = chatState.fileEnabled &&
|
||||
chatState.pluginState.state == RunningStatePB.Running;
|
||||
|
||||
final aiType = chatState.pluginState.state == RunningStatePB.Running
|
||||
? AIType.localAI
|
||||
: AIType.appflowyAI;
|
||||
emit(
|
||||
state.copyWith(
|
||||
aiType: aiType,
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
chatState: chatState,
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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-error/code.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
@@ -30,7 +31,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
super(ChatState.initial()) {
|
||||
_startListening();
|
||||
_dispatch();
|
||||
_init();
|
||||
_loadMessages();
|
||||
_loadSetting();
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
@@ -59,20 +61,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
AnswerStream? answerStream;
|
||||
int numSendMessage = 0;
|
||||
|
||||
/// a counter used to determine whether the initial loading state should be
|
||||
/// set to finish. It should hit two before we emit: one for the local fetch
|
||||
/// and another for the server fetch.
|
||||
///
|
||||
/// This is to work around a bug where if an ai chat that is not yet on the
|
||||
/// user local storage is opened but has messages in the server, it will
|
||||
/// remain stuck on the welcome screen until the user switches to another page
|
||||
/// then come back.
|
||||
int initialFetchCounter = 0;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await answerStream?.dispose();
|
||||
await listener.stop();
|
||||
final request = ViewIdPB(value: chatId);
|
||||
unawaited(FolderEventCloseView(request).send());
|
||||
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -86,14 +81,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
await chatController.insert(message, index: 0);
|
||||
}
|
||||
|
||||
if (initialFetchCounter < 2) {
|
||||
initialFetchCounter++;
|
||||
}
|
||||
|
||||
if (state.loadingState.isLoading && initialFetchCounter >= 2) {
|
||||
emit(
|
||||
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||
);
|
||||
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: () {
|
||||
@@ -165,18 +169,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
) {
|
||||
numSendMessage += 1;
|
||||
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
|
||||
_clearRelatedQuestions();
|
||||
_startStreamingMessage(message, metadata);
|
||||
lastSentMessage = null;
|
||||
|
||||
@@ -186,11 +179,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
finishSending: (ChatMessagePB message) {
|
||||
lastSentMessage = message;
|
||||
finishSending: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.awaitingAnswer,
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -228,14 +220,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
answerStream = null;
|
||||
answerStreamMessageId = '';
|
||||
},
|
||||
startAnswerStreaming: (Message message) {
|
||||
chatController.insert(message);
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.streamingAnswer,
|
||||
),
|
||||
);
|
||||
},
|
||||
failedSending: () {
|
||||
final lastMessage = chatController.messages.lastOrNull;
|
||||
if (lastMessage != null) {
|
||||
@@ -247,6 +231,33 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
regenerateAnswer: (id) {
|
||||
_clearRelatedQuestions();
|
||||
_regenerateAnswer(id);
|
||||
lastSentMessage = null;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.sendingQuestion,
|
||||
),
|
||||
);
|
||||
},
|
||||
didReceiveChatSettings: (settings) {
|
||||
emit(
|
||||
state.copyWith(selectedSourceIds: settings.ragIds),
|
||||
);
|
||||
},
|
||||
updateSelectedSources: (selectedSourcesIds) async {
|
||||
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
|
||||
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
ragIds: selectedSourcesIds,
|
||||
);
|
||||
await AIEventUpdateChatSettings(payload)
|
||||
.send()
|
||||
.onFailure(Log.error);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -261,15 +272,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
// 3 mean message response from AI
|
||||
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
answerStreamMessageId;
|
||||
temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => answerStreamMessageId,
|
||||
);
|
||||
answerStreamMessageId = '';
|
||||
}
|
||||
|
||||
// 1 mean message response from User
|
||||
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
|
||||
temporaryMessageIDMap[pb.messageId.toString()] =
|
||||
questionStreamMessageId;
|
||||
temporaryMessageIDMap.putIfAbsent(
|
||||
pb.messageId.toString(),
|
||||
() => questionStreamMessageId,
|
||||
);
|
||||
questionStreamMessageId = '';
|
||||
}
|
||||
|
||||
@@ -332,12 +347,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
final payload = LoadNextChatMessagePB(
|
||||
void _loadSetting() async {
|
||||
final getChatSettingsPayload =
|
||||
AIEventGetChatSettings(ChatId(value: chatId));
|
||||
await getChatSettingsPayload.send().fold(
|
||||
(settings) {
|
||||
if (!isClosed) {
|
||||
add(ChatEvent.didReceiveChatSettings(settings: settings));
|
||||
}
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
}
|
||||
|
||||
void _loadMessages() async {
|
||||
final loadMessagesPayload = LoadNextChatMessagePB(
|
||||
chatId: chatId,
|
||||
limit: Int64(10),
|
||||
);
|
||||
await AIEventLoadNextMessage(payload).send().fold(
|
||||
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
|
||||
(list) {
|
||||
if (!isClosed) {
|
||||
final messages = list.messages.map(_createTextMessage).toList();
|
||||
@@ -398,12 +426,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
(question) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
answerStream!,
|
||||
question.messageId,
|
||||
stream: answerStream!,
|
||||
questionMessageId: question.messageId,
|
||||
fakeQuestionMessageId: questionStreamMessage.id,
|
||||
);
|
||||
|
||||
add(ChatEvent.finishSending(question));
|
||||
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
||||
lastSentMessage = question;
|
||||
add(const ChatEvent.finishSending());
|
||||
add(ChatEvent.receiveMessage(streamAnswer));
|
||||
}
|
||||
},
|
||||
(err) {
|
||||
@@ -430,11 +460,50 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
Message _createAnswerStreamMessage(
|
||||
AnswerStream stream,
|
||||
Int64 questionMessageId,
|
||||
) {
|
||||
answerStreamMessageId = (questionMessageId + 1).toString();
|
||||
void _regenerateAnswer(String answerMessageIdString) async {
|
||||
final id = temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((e) => e.value == answerMessageIdString)
|
||||
?.key ??
|
||||
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),
|
||||
);
|
||||
|
||||
await AIEventRegenerateResponse(payload).send().fold(
|
||||
(success) {
|
||||
if (!isClosed) {
|
||||
final streamAnswer = _createAnswerStreamMessage(
|
||||
stream: answerStream!,
|
||||
questionMessageId: answerMessageId - 1,
|
||||
).copyWith(id: answerMessageIdString);
|
||||
|
||||
add(ChatEvent.receiveMessage(streamAnswer));
|
||||
add(const ChatEvent.finishSending());
|
||||
}
|
||||
},
|
||||
(err) => Log.error("Failed to send message: ${err.msg}"),
|
||||
);
|
||||
}
|
||||
|
||||
Message _createAnswerStreamMessage({
|
||||
required AnswerStream stream,
|
||||
required Int64 questionMessageId,
|
||||
String? fakeQuestionMessageId,
|
||||
}) {
|
||||
answerStreamMessageId = fakeQuestionMessageId == null
|
||||
? (questionMessageId + 1).toString()
|
||||
: "${fakeQuestionMessageId}_ans";
|
||||
|
||||
return TextMessage(
|
||||
id: answerStreamMessageId,
|
||||
@@ -488,22 +557,44 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _clearRelatedQuestions() {
|
||||
final relatedQuestionMessages = chatController.messages
|
||||
.where(
|
||||
(message) =>
|
||||
onetimeMessageTypeFromMeta(message.metadata) ==
|
||||
OnetimeShotType.relatedQuestion,
|
||||
)
|
||||
.toList();
|
||||
|
||||
for (final message in relatedQuestionMessages) {
|
||||
chatController.remove(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatEvent with _$ChatEvent {
|
||||
// chat settings
|
||||
const factory ChatEvent.didReceiveChatSettings({
|
||||
required ChatSettingsPB settings,
|
||||
}) = _DidReceiveChatSettings;
|
||||
const factory ChatEvent.updateSelectedSources({
|
||||
required List<String> selectedSourcesIds,
|
||||
}) = _UpdateSelectedSources;
|
||||
|
||||
// send message
|
||||
const factory ChatEvent.sendMessage({
|
||||
required String message,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) = _SendMessage;
|
||||
const factory ChatEvent.finishSending(ChatMessagePB message) =
|
||||
_FinishSendMessage;
|
||||
const factory ChatEvent.finishSending() = _FinishSendMessage;
|
||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||
|
||||
// regenerate
|
||||
const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer;
|
||||
|
||||
// streaming answer
|
||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||
_StartAnswerStreaming;
|
||||
const factory ChatEvent.stopStream() = _StopStream;
|
||||
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
|
||||
|
||||
@@ -528,12 +619,14 @@ class ChatEvent with _$ChatEvent {
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
required ChatLoadingState loadingState,
|
||||
required List<String> selectedSourceIds,
|
||||
required LoadChatMessageStatus loadingState,
|
||||
required PromptResponseState promptResponseState,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial() => const ChatState(
|
||||
loadingState: ChatLoadingState.loading(),
|
||||
selectedSourceIds: [],
|
||||
loadingState: LoadChatMessageStatus.loading,
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
);
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
class ChatEditDocumentService {
|
||||
static Future<ViewPB?> saveMessagesToNewPage(
|
||||
String chatPageName,
|
||||
String parentViewId,
|
||||
List<TextMessage> messages,
|
||||
) async {
|
||||
if (messages.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert messages to markdown and trim the last empty newline.
|
||||
final completeMessage = messages.map((m) => m.text).join('\n').trimRight();
|
||||
if (completeMessage.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final document = customMarkdownToDocument(completeMessage);
|
||||
final initialBytes =
|
||||
DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();
|
||||
if (initialBytes == null) {
|
||||
Log.error('Failed to convert messages to document');
|
||||
return null;
|
||||
}
|
||||
|
||||
return ViewBackendService.createView(
|
||||
name: LocaleKeys.chat_addToNewPageName.tr(args: [chatPageName]),
|
||||
layoutType: ViewLayoutPB.Document,
|
||||
parentViewId: parentViewId,
|
||||
initialDataBytes: initialBytes,
|
||||
).toNullable();
|
||||
}
|
||||
|
||||
static Future<void> addMessageToPage(
|
||||
String documentId,
|
||||
TextMessage message,
|
||||
) async {
|
||||
if (message.text.isEmpty) {
|
||||
Log.error('Message is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
final bloc = DocumentBloc(
|
||||
documentId: documentId,
|
||||
saveToBlocMap: false,
|
||||
)..add(const DocumentEvent.initial());
|
||||
|
||||
if (bloc.state.editorState == null) {
|
||||
await bloc.stream.firstWhere((state) => state.editorState != null);
|
||||
}
|
||||
|
||||
final editorState = bloc.state.editorState;
|
||||
if (editorState == null) {
|
||||
Log.error("Can't get EditorState of document");
|
||||
return;
|
||||
}
|
||||
|
||||
final messageDocument = customMarkdownToDocument(message.text);
|
||||
if (messageDocument.isEmpty) {
|
||||
Log.error('Failed to convert message to document');
|
||||
return;
|
||||
}
|
||||
|
||||
final lastNodeOrNull = editorState.document.root.children.lastOrNull;
|
||||
|
||||
final rootIsEmpty = lastNodeOrNull == null;
|
||||
final isLastLineEmpty = lastNodeOrNull?.children.isNotEmpty == false &&
|
||||
lastNodeOrNull?.delta?.isNotEmpty == false;
|
||||
|
||||
final nodes = [
|
||||
if (rootIsEmpty || !isLastLineEmpty) paragraphNode(),
|
||||
...messageDocument.root.children,
|
||||
paragraphNode(),
|
||||
];
|
||||
final insertPath = rootIsEmpty ||
|
||||
listEquals(lastNodeOrNull.path, const [0]) && isLastLineEmpty
|
||||
? const [0]
|
||||
: lastNodeOrNull.path.next;
|
||||
|
||||
final transaction = editorState.transaction..insertNodes(insertPath, nodes);
|
||||
await editorState.apply(transaction);
|
||||
await bloc.close();
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ class AIChatProgress {
|
||||
enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
awaitingAnswer,
|
||||
streamingAnswer,
|
||||
}
|
||||
|
||||
@@ -132,3 +131,9 @@ const onetimeShotType = "OnetimeShotType";
|
||||
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
|
||||
return metadata?[onetimeShotType];
|
||||
}
|
||||
|
||||
enum LoadChatMessageStatus {
|
||||
loading,
|
||||
loadingRemote,
|
||||
ready,
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
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';
|
||||
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/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'chat_select_sources_cubit.freezed.dart';
|
||||
|
||||
const int _kMaxSelectedParentPageCount = 3;
|
||||
|
||||
enum SourceSelectedStatus {
|
||||
unselected,
|
||||
selected,
|
||||
partiallySelected;
|
||||
|
||||
bool get isUnselected => this == unselected;
|
||||
bool get isSelected => this == selected;
|
||||
bool get isPartiallySelected => this == partiallySelected;
|
||||
}
|
||||
|
||||
class ChatSource {
|
||||
ChatSource({
|
||||
required this.view,
|
||||
required this.parentView,
|
||||
required this.children,
|
||||
required bool isExpanded,
|
||||
required SourceSelectedStatus selectedStatus,
|
||||
required IgnoreViewType ignoreStatus,
|
||||
}) : isExpandedNotifier = ValueNotifier(isExpanded),
|
||||
selectedStatusNotifier = ValueNotifier(selectedStatus),
|
||||
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
|
||||
|
||||
final ViewPB view;
|
||||
final ViewPB? parentView;
|
||||
final List<ChatSource> children;
|
||||
final ValueNotifier<bool> isExpandedNotifier;
|
||||
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
|
||||
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
|
||||
|
||||
bool get isExpanded => isExpandedNotifier.value;
|
||||
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
|
||||
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
|
||||
|
||||
void toggleIsExpanded() {
|
||||
isExpandedNotifier.value = !isExpanded;
|
||||
}
|
||||
|
||||
ChatSource copy() {
|
||||
return ChatSource(
|
||||
view: view,
|
||||
parentView: parentView,
|
||||
children: children.map<ChatSource>((child) => child.copy()).toList(),
|
||||
ignoreStatus: ignoreStatus,
|
||||
isExpanded: isExpanded,
|
||||
selectedStatus: selectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
ChatSource? findChildBySourceId(String sourceId) {
|
||||
if (view.id == sourceId) {
|
||||
return this;
|
||||
}
|
||||
for (final child in children) {
|
||||
final childResult = child.findChildBySourceId(sourceId);
|
||||
if (childResult != null) {
|
||||
return childResult;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void resetIgnoreViewTypeRecursive() {
|
||||
ignoreStatusNotifier.value = view.layout.isDocumentView
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.disable;
|
||||
|
||||
for (final child in children) {
|
||||
child.resetIgnoreViewTypeRecursive();
|
||||
}
|
||||
}
|
||||
|
||||
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
|
||||
ignoreStatusNotifier.value = newIgnoreViewType;
|
||||
for (final child in children) {
|
||||
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
for (final child in children) {
|
||||
child.dispose();
|
||||
}
|
||||
isExpandedNotifier.dispose();
|
||||
selectedStatusNotifier.dispose();
|
||||
ignoreStatusNotifier.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
ChatSettingsCubit() : super(ChatSettingsState.initial());
|
||||
|
||||
List<String> selectedSourceIds = [];
|
||||
List<ChatSource> sources = [];
|
||||
List<ChatSource> selectedSources = [];
|
||||
String filter = '';
|
||||
|
||||
void updateSelectedSources(List<String> newSelectedSourceIds) {
|
||||
selectedSourceIds = [...newSelectedSourceIds];
|
||||
}
|
||||
|
||||
void refreshSources(List<ViewPB> spaceViews) async {
|
||||
filter = "";
|
||||
|
||||
final newSources = await Future.wait(
|
||||
spaceViews.map((view) => _recursiveBuild(view, null)),
|
||||
);
|
||||
for (final source in newSources) {
|
||||
_restrictSelectionIfNecessary(source.children);
|
||||
}
|
||||
|
||||
final selected = newSources
|
||||
.map((source) => _buildSelectedSources(source))
|
||||
.flattened
|
||||
.toList();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedSources: selected,
|
||||
visibleSources: newSources,
|
||||
),
|
||||
);
|
||||
|
||||
sources
|
||||
..forEach((e) => e.dispose())
|
||||
..clear()
|
||||
..addAll(newSources.map((e) => e.copy()));
|
||||
|
||||
selectedSources
|
||||
..forEach((e) => e.dispose())
|
||||
..clear()
|
||||
..addAll(selected.map((e) => e.copy()));
|
||||
}
|
||||
|
||||
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
|
||||
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
|
||||
final isThisSourceSelected = selectedSourceIds.contains(view.id);
|
||||
|
||||
final childrenViews =
|
||||
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
|
||||
|
||||
int selectedCount = 0;
|
||||
final children = <ChatSource>[];
|
||||
|
||||
if (childrenViews != null) {
|
||||
for (final childView in childrenViews) {
|
||||
if (childView.layout == ViewLayoutPB.Chat) {
|
||||
continue;
|
||||
}
|
||||
final childChatSource = await _recursiveBuild(childView, view);
|
||||
if (childChatSource.selectedStatus.isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
children.add(childChatSource);
|
||||
}
|
||||
|
||||
final areAllChildrenSelectedOrNoChildren =
|
||||
children.length == selectedCount;
|
||||
final isAnyChildNotUnselected =
|
||||
children.any((e) => !e.selectedStatus.isUnselected);
|
||||
|
||||
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
|
||||
selectedStatus = SourceSelectedStatus.partiallySelected;
|
||||
}
|
||||
} else if (isThisSourceSelected) {
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
}
|
||||
|
||||
return ChatSource(
|
||||
view: view,
|
||||
parentView: parentView,
|
||||
children: children,
|
||||
ignoreStatus: view.layout.isDocumentView
|
||||
? IgnoreViewType.none
|
||||
: IgnoreViewType.disable,
|
||||
isExpanded: false,
|
||||
selectedStatus: selectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
|
||||
for (final source in sources) {
|
||||
source.resetIgnoreViewTypeRecursive();
|
||||
}
|
||||
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
|
||||
_kMaxSelectedParentPageCount) {
|
||||
sources
|
||||
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
|
||||
.forEach(
|
||||
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void updateFilter(String filter) {
|
||||
this.filter = filter;
|
||||
for (final source in state.visibleSources) {
|
||||
source.dispose();
|
||||
}
|
||||
if (sources.isEmpty) {
|
||||
emit(ChatSettingsState.initial());
|
||||
} else {
|
||||
final selected =
|
||||
selectedSources.map(_buildSearchResults).nonNulls.toList();
|
||||
final visible =
|
||||
sources.map(_buildSearchResults).nonNulls.nonNulls.toList();
|
||||
emit(
|
||||
state.copyWith(
|
||||
selectedSources: selected,
|
||||
visibleSources: visible,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// traverse tree to build up search query
|
||||
ChatSource? _buildSearchResults(ChatSource chatSource) {
|
||||
final isVisible = chatSource.view.nameOrDefault
|
||||
.toLowerCase()
|
||||
.contains(filter.toLowerCase());
|
||||
|
||||
final childrenResults = <ChatSource>[];
|
||||
for (final childSource in chatSource.children) {
|
||||
final childResult = _buildSearchResults(childSource);
|
||||
if (childResult != null) {
|
||||
childrenResults.add(childResult);
|
||||
}
|
||||
}
|
||||
|
||||
return isVisible || childrenResults.isNotEmpty
|
||||
? ChatSource(
|
||||
view: chatSource.view,
|
||||
parentView: chatSource.parentView,
|
||||
children: childrenResults,
|
||||
ignoreStatus: chatSource.ignoreStatus,
|
||||
isExpanded: chatSource.isExpanded,
|
||||
selectedStatus: chatSource.selectedStatus,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// traverse tree to build up selected sources
|
||||
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
|
||||
final children = <ChatSource>[];
|
||||
|
||||
for (final childSource in chatSource.children) {
|
||||
children.addAll(_buildSelectedSources(childSource));
|
||||
}
|
||||
|
||||
return selectedSourceIds.contains(chatSource.view.id)
|
||||
? [
|
||||
ChatSource(
|
||||
view: chatSource.view,
|
||||
parentView: chatSource.parentView,
|
||||
children: children,
|
||||
ignoreStatus: chatSource.ignoreStatus,
|
||||
selectedStatus: chatSource.selectedStatus,
|
||||
isExpanded: true,
|
||||
),
|
||||
]
|
||||
: children;
|
||||
}
|
||||
|
||||
void toggleSelectedStatus(ChatSource chatSource) {
|
||||
if (chatSource.view.isSpace) {
|
||||
return;
|
||||
}
|
||||
final allIds = _recursiveGetSourceIds(chatSource);
|
||||
|
||||
if (chatSource.selectedStatus.isUnselected ||
|
||||
chatSource.selectedStatus.isPartiallySelected &&
|
||||
!chatSource.view.layout.isDocumentView) {
|
||||
for (final id in allIds) {
|
||||
if (!selectedSourceIds.contains(id)) {
|
||||
selectedSourceIds.add(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (final id in allIds) {
|
||||
if (selectedSourceIds.contains(id)) {
|
||||
selectedSourceIds.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedStatus();
|
||||
}
|
||||
|
||||
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
|
||||
return [
|
||||
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
|
||||
for (final childSource in chatSource.children)
|
||||
..._recursiveGetSourceIds(childSource),
|
||||
];
|
||||
}
|
||||
|
||||
void updateSelectedStatus() {
|
||||
if (sources.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (final source in sources) {
|
||||
_recursiveUpdateSelectedStatus(source);
|
||||
}
|
||||
_restrictSelectionIfNecessary(sources);
|
||||
for (final visibleSource in state.visibleSources) {
|
||||
visibleSource.dispose();
|
||||
}
|
||||
final visible = sources.map(_buildSearchResults).nonNulls.toList();
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
visibleSources: visible,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
|
||||
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
|
||||
|
||||
int selectedCount = 0;
|
||||
for (final childSource in chatSource.children) {
|
||||
final childStatus = _recursiveUpdateSelectedStatus(childSource);
|
||||
if (childStatus.isSelected) {
|
||||
selectedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
|
||||
final areAllChildrenSelectedOrNoChildren =
|
||||
chatSource.children.length == selectedCount;
|
||||
final isAnyChildNotUnselected =
|
||||
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
|
||||
|
||||
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
|
||||
selectedStatus = SourceSelectedStatus.selected;
|
||||
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
|
||||
selectedStatus = SourceSelectedStatus.partiallySelected;
|
||||
}
|
||||
|
||||
chatSource.selectedStatusNotifier.value = selectedStatus;
|
||||
return selectedStatus;
|
||||
}
|
||||
|
||||
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
|
||||
chatSource.toggleIsExpanded();
|
||||
if (isSelectedSection) {
|
||||
for (final selectedSource in selectedSources) {
|
||||
selectedSource
|
||||
.findChildBySourceId(chatSource.view.id)
|
||||
?.toggleIsExpanded();
|
||||
}
|
||||
} else {
|
||||
for (final source in sources) {
|
||||
final child = source.findChildBySourceId(chatSource.view.id);
|
||||
if (child != null) {
|
||||
child.toggleIsExpanded();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
for (final child in sources) {
|
||||
child.dispose();
|
||||
}
|
||||
for (final child in selectedSources) {
|
||||
child.dispose();
|
||||
}
|
||||
for (final child in state.selectedSources) {
|
||||
child.dispose();
|
||||
}
|
||||
for (final child in state.visibleSources) {
|
||||
child.dispose();
|
||||
}
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ChatSettingsState with _$ChatSettingsState {
|
||||
const factory ChatSettingsState({
|
||||
required List<ChatSource> visibleSources,
|
||||
required List<ChatSource> selectedSources,
|
||||
}) = _ChatSettingsState;
|
||||
|
||||
factory ChatSettingsState.initial() => const ChatSettingsState(
|
||||
visibleSources: [],
|
||||
selectedSources: [],
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
@@ -28,14 +18,20 @@ import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'application/ai_prompt_input_bloc.dart';
|
||||
import 'application/chat_bloc.dart';
|
||||
import 'application/chat_entity.dart';
|
||||
import 'application/chat_member_bloc.dart';
|
||||
import 'application/chat_message_stream.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||
import 'presentation/chat_related_question.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
import 'presentation/layout_define.dart';
|
||||
import 'presentation/message/ai_text_message.dart';
|
||||
import 'presentation/message/error_text_message.dart';
|
||||
import 'presentation/message/message_util.dart';
|
||||
import 'presentation/message/user_text_message.dart';
|
||||
import 'presentation/scroll_to_bottom.dart';
|
||||
|
||||
@@ -120,20 +116,13 @@ class _ChatContentPage extends StatelessWidget {
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return state.loadingState.when(
|
||||
loading: () {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
},
|
||||
finish: (_) {
|
||||
final chatController =
|
||||
context.read<ChatBloc>().chatController;
|
||||
return Column(
|
||||
return switch (state.loadingState) {
|
||||
LoadChatMessageStatus.ready => Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Chat(
|
||||
chatController: chatController,
|
||||
chatController:
|
||||
context.read<ChatBloc>().chatController,
|
||||
user: User(id: userProfile.id.toString()),
|
||||
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||
@@ -148,9 +137,9 @@ class _ChatContentPage extends StatelessWidget {
|
||||
),
|
||||
_buildInput(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
_ => const Center(child: CircularProgressIndicator.adaptive()),
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -204,14 +193,13 @@ class _ChatContentPage extends StatelessWidget {
|
||||
final refSourceJsonString =
|
||||
message.metadata?[messageRefSourceJsonStringKey] as String?;
|
||||
|
||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
return BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
final chatController = context.read<ChatBloc>().chatController;
|
||||
final messages = chatController.messages
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
return messages.isEmpty ? false : messages.last.id == message.id;
|
||||
},
|
||||
builder: (context, isLastMessage) {
|
||||
final isLastMessage =
|
||||
messages.isEmpty ? false : messages.last.id == message.id;
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
@@ -220,9 +208,13 @@ class _ChatContentPage extends StatelessWidget {
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isStreaming: state.promptResponseState != PromptResponseState.ready,
|
||||
isLastMessage: isLastMessage,
|
||||
onSelectedMetadata: (metadata) =>
|
||||
_onSelectMetadata(context, metadata),
|
||||
onRegenerate: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id)),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -287,36 +279,52 @@ class _ChatContentPage extends StatelessWidget {
|
||||
return state.promptResponseState == PromptResponseState.ready;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream()),
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: MobileAIPromptInput(
|
||||
chatId: view.id,
|
||||
onSubmitted: (text, metadata) {
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(const ChatEvent.stopStream()),
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
onSubmitted: (text, metadata) {
|
||||
chatBloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: text,
|
||||
metadata: metadata,
|
||||
),
|
||||
);
|
||||
},
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
selectedSourcesIds: ids,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -343,19 +351,8 @@ class _ChatContentPage extends StatelessWidget {
|
||||
} else {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (sidebarView == null) {
|
||||
return;
|
||||
}
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openSecondaryPlugin(
|
||||
plugin: sidebarView.plugin(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
unawaited(context.pushView(sidebarView));
|
||||
}
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, sidebarView);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class ChatEditorStyleCustomizer extends EditorStyleCustomizer {
|
||||
DefaultAppearanceSettings.getDefaultSelectionColor(context),
|
||||
defaultTextDirection: appearance.defaultTextDirection,
|
||||
textStyleConfiguration: TextStyleConfiguration(
|
||||
lineHeight: 1.4,
|
||||
lineHeight: 20 / 14,
|
||||
applyHeightToFirstAscent: true,
|
||||
applyHeightToLastDescent: true,
|
||||
text: baseTextStyle(fontFamily).copyWith(
|
||||
|
||||
+5
-4
@@ -1,6 +1,8 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view_title/view_title_bar_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
|
||||
@@ -308,10 +310,9 @@ class MentionViewIcon extends StatelessWidget {
|
||||
if (view.icon.value.isNotEmpty) {
|
||||
return SizedBox(
|
||||
width: 16.0,
|
||||
child: FlowyText.emoji(
|
||||
view.icon.value,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 21.0,
|
||||
child: EmojiIconWidget(
|
||||
emoji: view.icon.toEmojiIconData(),
|
||||
emojiSize: 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+79
-47
@@ -10,13 +10,13 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import 'chat_mention_page_menu.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_file.dart';
|
||||
import 'chat_input_span.dart';
|
||||
import 'chat_mention_page_menu.dart';
|
||||
import 'select_sources_menu.dart';
|
||||
|
||||
class DesktopAIPromptInput extends StatefulWidget {
|
||||
const DesktopAIPromptInput({
|
||||
@@ -25,12 +25,14 @@ class DesktopAIPromptInput extends StatefulWidget {
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
||||
@@ -112,6 +114,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
color: focusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: focusNode.hasFocus ? 1.5 : 1.0,
|
||||
),
|
||||
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
|
||||
),
|
||||
@@ -136,9 +139,10 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
Stack(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
|
||||
DesktopAIPromptSizes.actionBarHeight,
|
||||
DesktopAIPromptSizes.actionBarHeight +
|
||||
DesktopAIPromptSizes.actionBarPadding.vertical,
|
||||
maxHeight: 300,
|
||||
),
|
||||
child: inputTextField(),
|
||||
@@ -153,6 +157,8 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
sendButtonState: sendButtonState,
|
||||
onSendPressed: handleSendPressed,
|
||||
onStopStreaming: widget.onStopStreaming,
|
||||
onUpdateSelectedSources:
|
||||
widget.onUpdateSelectedSources,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -184,6 +190,9 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
}
|
||||
|
||||
void handleSendPressed() {
|
||||
if (widget.isStreaming) {
|
||||
return;
|
||||
}
|
||||
final trimmedText = inputControlCubit.formatIntputText(
|
||||
textController.text.trim(),
|
||||
);
|
||||
@@ -207,6 +216,10 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
setState(() => updateSendButtonState());
|
||||
|
||||
// handle text and selection changes ONLY when mentioning a page
|
||||
|
||||
// disable mention
|
||||
return;
|
||||
// ignore: dead_code
|
||||
if (!overlayController.isShowing ||
|
||||
inputControlCubit.filterStartPosition == -1) {
|
||||
return;
|
||||
@@ -292,12 +305,12 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
onStartMentioningPage: () {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
inputControlCubit.startSearching(textController.value);
|
||||
overlayController.show();
|
||||
});
|
||||
},
|
||||
// onStartMentioningPage: () {
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// inputControlCubit.startSearching(textController.value);
|
||||
// overlayController.show();
|
||||
// });
|
||||
// },
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -353,14 +366,14 @@ class _PromptTextField extends StatefulWidget {
|
||||
required this.cubit,
|
||||
required this.textController,
|
||||
required this.textFieldFocusNode,
|
||||
required this.onStartMentioningPage,
|
||||
// required this.onStartMentioningPage,
|
||||
this.hintText = "",
|
||||
});
|
||||
|
||||
final ChatInputControlCubit cubit;
|
||||
final TextEditingController textController;
|
||||
final FocusNode textFieldFocusNode;
|
||||
final void Function() onStartMentioningPage;
|
||||
// final void Function() onStartMentioningPage;
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
@@ -373,13 +386,13 @@ class _PromptTextFieldState extends State<_PromptTextField> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.textFieldFocusNode.onKeyEvent = handleKeyEvent;
|
||||
// widget.textFieldFocusNode.onKeyEvent = handleKeyEvent;
|
||||
widget.textController.addListener(onTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.textFieldFocusNode.onKeyEvent = null;
|
||||
// widget.textFieldFocusNode.onKeyEvent = null;
|
||||
widget.textController.removeListener(onTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -424,12 +437,12 @@ class _PromptTextFieldState extends State<_PromptTextField> {
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event.character == '@') {
|
||||
widget.onStartMentioningPage();
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
// if (event.character == '@') {
|
||||
// widget.onStartMentioningPage();
|
||||
// }
|
||||
// return KeyEventResult.ignored;
|
||||
// }
|
||||
|
||||
void onTextChanged() {
|
||||
setState(
|
||||
@@ -475,6 +488,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
required this.sendButtonState,
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final TextEditingController textController;
|
||||
@@ -483,24 +497,36 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
final SendButtonState sendButtonState;
|
||||
final void Function() onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: DesktopAIPromptSizes.actionBarHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
margin: DesktopAIPromptSizes.actionBarPadding,
|
||||
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
if (state.chatState == null) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: _sendButton(),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// predefinedFormatButton(),
|
||||
const Spacer(),
|
||||
_mentionButton(context),
|
||||
const HSpace(
|
||||
DesktopAIPromptSizes.actionBarButtonSpacing,
|
||||
),
|
||||
if (UniversalPlatform.isDesktop && state.supportChatWithFile) ...[
|
||||
if (state.aiType == AIType.appflowyAI) ...[
|
||||
_selectSourcesButton(context),
|
||||
const HSpace(
|
||||
DesktopAIPromptSizes.actionBarButtonSpacing,
|
||||
),
|
||||
],
|
||||
// _mentionButton(context),
|
||||
// const HSpace(
|
||||
// DesktopAIPromptSizes.actionBarButtonSpacing,
|
||||
// ),
|
||||
if (state.supportChatWithFile) ...[
|
||||
_attachmentButton(context),
|
||||
const HSpace(
|
||||
DesktopAIPromptSizes.actionBarButtonSpacing,
|
||||
@@ -514,28 +540,34 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mentionButton(BuildContext context) {
|
||||
return PromptInputMentionButton(
|
||||
iconSize: DesktopAIPromptSizes.actionBarIconSize,
|
||||
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
onTap: () {
|
||||
if (overlayController.isShowing) {
|
||||
return;
|
||||
}
|
||||
if (!focusNode.hasFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
textController.text += '@';
|
||||
Future.delayed(Duration.zero, () {
|
||||
context
|
||||
.read<ChatInputControlCubit>()
|
||||
.startSearching(textController.value);
|
||||
overlayController.show();
|
||||
});
|
||||
},
|
||||
Widget _selectSourcesButton(BuildContext context) {
|
||||
return PromptInputDesktopSelectSourcesButton(
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
);
|
||||
}
|
||||
|
||||
// Widget _mentionButton(BuildContext context) {
|
||||
// return PromptInputMentionButton(
|
||||
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
|
||||
// buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
// onTap: () {
|
||||
// if (overlayController.isShowing) {
|
||||
// return;
|
||||
// }
|
||||
// if (!focusNode.hasFocus) {
|
||||
// focusNode.requestFocus();
|
||||
// }
|
||||
// textController.text += '@';
|
||||
// Future.delayed(Duration.zero, () {
|
||||
// context
|
||||
// .read<ChatInputControlCubit>()
|
||||
// .startSearching(textController.value);
|
||||
// overlayController.show();
|
||||
// });
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
Widget _attachmentButton(BuildContext context) {
|
||||
return PromptInputAttachmentButton(
|
||||
onTap: () async {
|
||||
@@ -562,7 +594,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
|
||||
Widget _sendButton() {
|
||||
return PromptInputSendButton(
|
||||
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
buttonSize: DesktopAIPromptSizes.actionBarHeight,
|
||||
iconSize: DesktopAIPromptSizes.sendButtonSize,
|
||||
state: sendButtonState,
|
||||
onSendPressed: onSendPressed,
|
||||
|
||||
+58
-30
@@ -13,6 +13,7 @@ import 'ai_prompt_buttons.dart';
|
||||
import 'chat_input_file.dart';
|
||||
import 'chat_input_span.dart';
|
||||
import 'chat_mention_page_bottom_sheet.dart';
|
||||
import 'select_sources_bottom_sheet.dart';
|
||||
|
||||
class MobileAIPromptInput extends StatefulWidget {
|
||||
const MobileAIPromptInput({
|
||||
@@ -21,12 +22,14 @@ class MobileAIPromptInput extends StatefulWidget {
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
|
||||
@@ -44,7 +47,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
super.initState();
|
||||
|
||||
textController.addListener(handleTextControllerChange);
|
||||
focusNode.onKeyEvent = handleKeyEvent;
|
||||
// focusNode.onKeyEvent = handleKeyEvent;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
focusNode.requestFocus();
|
||||
@@ -119,16 +122,16 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
minHeight: MobileAIPromptSizes.textFieldMinHeight,
|
||||
maxHeight: 220,
|
||||
),
|
||||
padding:
|
||||
const EdgeInsetsDirectional.fromSTEB(0, 8.0, 12.0, 8.0),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8.0),
|
||||
Expanded(child: inputTextField(context)),
|
||||
mentionButton(),
|
||||
const HSpace(6.0),
|
||||
leadingButtons(context),
|
||||
Expanded(
|
||||
child: inputTextField(context),
|
||||
),
|
||||
sendButton(),
|
||||
const HSpace(12.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -152,6 +155,9 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
}
|
||||
|
||||
void handleSendPressed() {
|
||||
if (widget.isStreaming) {
|
||||
return;
|
||||
}
|
||||
final trimmedText = inputControlCubit.formatIntputText(
|
||||
textController.text.trim(),
|
||||
);
|
||||
@@ -170,18 +176,18 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
if (textController.value.isComposingRangeValid) {
|
||||
return;
|
||||
}
|
||||
inputControlCubit.updateInputText(textController.text);
|
||||
// inputControlCubit.updateInputText(textController.text);
|
||||
setState(() => updateSendButtonState());
|
||||
}
|
||||
|
||||
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event.character == '@') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
mentionPage(context);
|
||||
});
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
// if (event.character == '@') {
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// mentionPage(context);
|
||||
// });
|
||||
// }
|
||||
// return KeyEventResult.ignored;
|
||||
// }
|
||||
|
||||
Future<void> mentionPage(BuildContext context) async {
|
||||
// if the focus node is on focus, unfocus it for better animation
|
||||
@@ -259,28 +265,30 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget mentionButton() {
|
||||
return Align(
|
||||
Widget leadingButtons(BuildContext context) {
|
||||
return Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: PromptInputMentionButton(
|
||||
iconSize: MobileAIPromptSizes.mentionIconSize,
|
||||
buttonSize: MobileAIPromptSizes.sendButtonSize,
|
||||
onTap: () {
|
||||
textController.text += '@';
|
||||
if (!focusNode.hasFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
mentionPage(context);
|
||||
});
|
||||
},
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: _LeadingActions(
|
||||
textController: textController,
|
||||
// onMention: () {
|
||||
// textController.text += '@';
|
||||
// if (!focusNode.hasFocus) {
|
||||
// focusNode.requestFocus();
|
||||
// }
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// mentionPage(context);
|
||||
// });
|
||||
// },
|
||||
onUpdateSelectedSources: widget.onUpdateSelectedSources,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget sendButton() {
|
||||
return Align(
|
||||
return Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: PromptInputSendButton(
|
||||
buttonSize: MobileAIPromptSizes.sendButtonSize,
|
||||
iconSize: MobileAIPromptSizes.sendButtonSize,
|
||||
@@ -291,3 +299,23 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeadingActions extends StatelessWidget {
|
||||
const _LeadingActions({
|
||||
required this.textController,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final TextEditingController textController;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: PromptInputMobileSelectSourcesButton(
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
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_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'select_sources_menu.dart';
|
||||
|
||||
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
|
||||
const PromptInputMobileSelectSourcesButton({
|
||||
super.key,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<PromptInputMobileSelectSourcesButton> createState() =>
|
||||
_PromptInputMobileSelectSourcesButtonState();
|
||||
}
|
||||
|
||||
class _PromptInputMobileSelectSourcesButtonState
|
||||
extends State<PromptInputMobileSelectSourcesButton> {
|
||||
late final cubit = ChatSettingsCubit();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
cubit.updateSelectedSources(
|
||||
context.read<ChatBloc>().state.selectedSourceIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
|
||||
builder: (context, state) {
|
||||
final userProfile = context.read<UserWorkspaceBloc>().userProfile;
|
||||
final workspaceId = state.currentWorkspace?.workspaceId ?? '';
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
key: ValueKey(workspaceId),
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: cubit,
|
||||
),
|
||||
],
|
||||
child: BlocSelector<SpaceBloc, SpaceState, List<ViewPB>>(
|
||||
selector: (state) => state.spaces,
|
||||
builder: (context, spaces) {
|
||||
return BlocListener<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
cubit
|
||||
..updateSelectedSources(state.selectedSourceIds)
|
||||
..updateSelectedStatus();
|
||||
},
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
|
||||
expandText: false,
|
||||
text: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_page_s,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
size: const Size.square(20.0),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(10),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
context.read<ChatSettingsCubit>().refreshSources(spaces);
|
||||
await showMobileBottomSheet<void>(
|
||||
context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
maxChildSize: 0.98,
|
||||
enableDraggableScrollable: true,
|
||||
scrollableWidgetBuilder: (_, scrollController) {
|
||||
return Expanded(
|
||||
child: BlocProvider.value(
|
||||
value: cubit,
|
||||
child: _MobileSelectSourcesSheetBody(
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context) => const SizedBox.shrink(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBody extends StatefulWidget {
|
||||
const _MobileSelectSourcesSheetBody({
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
State<_MobileSelectSourcesSheetBody> createState() =>
|
||||
_MobileSelectSourcesSheetBodyState();
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBodyState
|
||||
extends State<_MobileSelectSourcesSheetBody> {
|
||||
final textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomScrollView(
|
||||
controller: widget.scrollController,
|
||||
shrinkWrap: true,
|
||||
slivers: [
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _Header(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).cardColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const DragHandle(),
|
||||
SizedBox(
|
||||
height: 44.0,
|
||||
child: Center(
|
||||
child: FlowyText.medium(
|
||||
LocaleKeys.chat_selectSources.tr(),
|
||||
fontSize: 16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 44.0,
|
||||
child: FlowySearchTextField(
|
||||
controller: textController,
|
||||
onChanged: (value) => context
|
||||
.read<ChatSettingsCubit>()
|
||||
.updateFilter(value),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 0.5, thickness: 0.5),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
builder: (context, state) {
|
||||
final sources = state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
childCount: sources.length,
|
||||
(context, index) {
|
||||
final source = sources.elementAt(index);
|
||||
return ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'visible_select_sources_tree_item_${source.view.id}',
|
||||
),
|
||||
chatSource: source,
|
||||
level: 0,
|
||||
isDescendentOfSpace: source.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
onSelected: (chatSource) {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleSelectedStatus(chatSource);
|
||||
},
|
||||
height: 40.0,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends SliverPersistentHeaderDelegate {
|
||||
const _Header({
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context,
|
||||
double shrinkOffset,
|
||||
bool overlapsContent,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
double get maxExtent => 120.5;
|
||||
|
||||
@override
|
||||
double get minExtent => 120.5;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
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_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.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:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
import 'chat_mention_page_menu.dart';
|
||||
|
||||
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
|
||||
const PromptInputDesktopSelectSourcesButton({
|
||||
super.key,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
State<PromptInputDesktopSelectSourcesButton> createState() =>
|
||||
_PromptInputDesktopSelectSourcesButtonState();
|
||||
}
|
||||
|
||||
class _PromptInputDesktopSelectSourcesButtonState
|
||||
extends State<PromptInputDesktopSelectSourcesButton> {
|
||||
late final cubit = ChatSettingsCubit();
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
cubit.updateSelectedSources(
|
||||
context.read<ChatBloc>().state.selectedSourceIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.userProfile;
|
||||
final workspaceId =
|
||||
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
|
||||
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) => SpaceBloc(
|
||||
userProfile: userProfile,
|
||||
workspaceId: workspaceId,
|
||||
)..add(const SpaceEvent.initial(openFirstPage: false)),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: cubit,
|
||||
),
|
||||
],
|
||||
child: BlocSelector<SpaceBloc, SpaceState, List<ViewPB>>(
|
||||
selector: (state) => state.spaces,
|
||||
builder: (context, spaces) {
|
||||
return BlocListener<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
cubit
|
||||
..updateSelectedSources(state.selectedSourceIds)
|
||||
..updateSelectedStatus();
|
||||
},
|
||||
child: AppFlowyPopover(
|
||||
constraints: BoxConstraints.loose(const Size(320, 380)),
|
||||
offset: const Offset(0.0, -10.0),
|
||||
direction: PopoverDirection.topWithCenterAligned,
|
||||
margin: EdgeInsets.zero,
|
||||
controller: popoverController,
|
||||
onOpen: () {
|
||||
context.read<ChatSettingsCubit>().refreshSources(spaces);
|
||||
},
|
||||
onClose: () {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
context.read<ChatSettingsCubit>().refreshSources(spaces);
|
||||
},
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: const _PopoverContent(),
|
||||
);
|
||||
},
|
||||
child: _IndicatorButton(
|
||||
onTap: () => popoverController.show(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicatorButton extends StatelessWidget {
|
||||
const _IndicatorButton({
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
child: FlowyHover(
|
||||
style: const HoverStyle(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_page_s,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
return FlowyText(
|
||||
state.selectedSourceIds.length.toString(),
|
||||
fontSize: 14,
|
||||
figmaLineHeight: 16,
|
||||
color: Theme.of(context).hintColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(2.0),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PopoverContent extends StatelessWidget {
|
||||
const _PopoverContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
builder: (context, state) {
|
||||
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),
|
||||
),
|
||||
),
|
||||
_buildDivider(),
|
||||
Flexible(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
children: [
|
||||
..._buildSelectedSources(context, state),
|
||||
if (state.selectedSources.isNotEmpty &&
|
||||
state.visibleSources.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: _buildDivider(),
|
||||
),
|
||||
..._buildVisibleSources(context, state),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
indent: 8.0,
|
||||
endIndent: 8.0,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildSelectedSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSourceTreeItem extends StatefulWidget {
|
||||
const ChatSourceTreeItem({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
required this.onSelected,
|
||||
required this.height,
|
||||
this.showCheckbox = true,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
|
||||
/// nested level of the view item
|
||||
final int level;
|
||||
|
||||
final bool isDescendentOfSpace;
|
||||
|
||||
final bool isSelectedSection;
|
||||
|
||||
final void Function(ChatSource chatSource) onSelected;
|
||||
|
||||
final double height;
|
||||
|
||||
final bool showCheckbox;
|
||||
|
||||
@override
|
||||
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
|
||||
}
|
||||
|
||||
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = SizedBox(
|
||||
height: widget.height,
|
||||
child: ChatSourceTreeItemInner(
|
||||
chatSource: widget.chatSource,
|
||||
level: widget.level,
|
||||
isDescendentOfSpace: widget.isDescendentOfSpace,
|
||||
isSelectedSection: widget.isSelectedSection,
|
||||
showCheckbox: widget.showCheckbox,
|
||||
onSelected: widget.onSelected,
|
||||
),
|
||||
);
|
||||
|
||||
final disabledEnabledChild =
|
||||
widget.chatSource.ignoreStatus == IgnoreViewType.disable
|
||||
? FlowyTooltip(
|
||||
message: widget.showCheckbox
|
||||
? switch (widget.chatSource.view.layout) {
|
||||
ViewLayoutPB.Document =>
|
||||
LocaleKeys.chat_sourcesLimitReached.tr(),
|
||||
_ => LocaleKeys.chat_sourceUnsupported.tr(),
|
||||
}
|
||||
: "",
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.forbidden,
|
||||
child: IgnorePointer(child: child),
|
||||
),
|
||||
),
|
||||
)
|
||||
: child;
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.chatSource.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();
|
||||
|
||||
if (!isExpanded || childViews.isEmpty) {
|
||||
return disabledEnabledChild;
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
disabledEnabledChild,
|
||||
...childViews.map(
|
||||
(childSource) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'select_sources_tree_item_${childSource.view.id}',
|
||||
),
|
||||
chatSource: childSource,
|
||||
level: widget.level + 1,
|
||||
isDescendentOfSpace: widget.isDescendentOfSpace,
|
||||
isSelectedSection: widget.isSelectedSection,
|
||||
onSelected: widget.onSelected,
|
||||
height: widget.height,
|
||||
showCheckbox: widget.showCheckbox,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatSourceTreeItemInner extends StatelessWidget {
|
||||
const ChatSourceTreeItemInner({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.level,
|
||||
required this.isDescendentOfSpace,
|
||||
required this.isSelectedSection,
|
||||
required this.showCheckbox,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final int level;
|
||||
final bool isDescendentOfSpace;
|
||||
final bool isSelectedSection;
|
||||
final bool showCheckbox;
|
||||
final void Function(ChatSource)? onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyHover(
|
||||
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
|
||||
style: HoverStyle(
|
||||
hoverColor: isSelectedSection
|
||||
? Colors.transparent
|
||||
: AFThemeExtension.of(context).lightGreyHover,
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
if (!isSelectedSection) {
|
||||
onSelected?.call(chatSource);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(4.0),
|
||||
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
|
||||
// builds the >, ^ or · button
|
||||
ToggleIsExpandedButton(
|
||||
chatSource: chatSource,
|
||||
isSelectedSection: isSelectedSection,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
// checkbox
|
||||
if (!chatSource.view.isSpace && showCheckbox) ...[
|
||||
SourceSelectedStatusCheckbox(
|
||||
chatSource: chatSource,
|
||||
),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
// icon
|
||||
MentionViewIcon(
|
||||
view: chatSource.view,
|
||||
),
|
||||
const HSpace(6.0),
|
||||
// title
|
||||
Expanded(
|
||||
child: FlowyText(
|
||||
chatSource.view.nameOrDefault,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ToggleIsExpandedButton extends StatelessWidget {
|
||||
const ToggleIsExpandedButton({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
required this.isSelectedSection,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
final bool isSelectedSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
|
||||
return const _DotIconWidget();
|
||||
}
|
||||
|
||||
if (chatSource.children.isEmpty) {
|
||||
return const SizedBox.square(dimension: 16.0);
|
||||
}
|
||||
|
||||
return FlowyHover(
|
||||
child: GestureDetector(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: chatSource.isExpandedNotifier,
|
||||
builder: (context, value, _) => FlowySvg(
|
||||
value
|
||||
? FlowySvgs.view_item_expand_s
|
||||
: FlowySvgs.view_item_unexpand_s,
|
||||
size: const Size.square(16.0),
|
||||
),
|
||||
),
|
||||
onTap: () => context
|
||||
.read<ChatSettingsCubit>()
|
||||
.toggleIsExpanded(chatSource, isSelectedSection),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DotIconWidget extends StatelessWidget {
|
||||
const _DotIconWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SourceSelectedStatusCheckbox extends StatelessWidget {
|
||||
const SourceSelectedStatusCheckbox({
|
||||
super.key,
|
||||
required this.chatSource,
|
||||
});
|
||||
|
||||
final ChatSource chatSource;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: chatSource.selectedStatusNotifier,
|
||||
builder: (context, selectedStatus, _) => FlowySvg(
|
||||
switch (selectedStatus) {
|
||||
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
|
||||
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
|
||||
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
|
||||
},
|
||||
size: const Size.square(18.0),
|
||||
blendMode: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ class AIChatUILayout {
|
||||
query.padding.right,
|
||||
query.viewInsets.bottom + query.padding.bottom,
|
||||
)
|
||||
: const EdgeInsets.only(bottom: 16);
|
||||
: const EdgeInsets.only(bottom: 24);
|
||||
}
|
||||
|
||||
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
|
||||
@@ -20,22 +20,23 @@ class AIChatUILayout {
|
||||
}
|
||||
|
||||
class DesktopAIPromptSizes {
|
||||
static const promptFrameRadius = BorderRadius.all(Radius.circular(8));
|
||||
static const promptFrameRadius = BorderRadius.all(Radius.circular(12.0));
|
||||
|
||||
static const attachedFilesBarPadding =
|
||||
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0);
|
||||
static const attachedFilesPreviewHeight = 48.0;
|
||||
static const attachedFilesPreviewSpacing = 12.0;
|
||||
|
||||
static const textFieldMinHeight = 36.0;
|
||||
static const textFieldMinHeight = 40.0;
|
||||
static const textFieldContentPadding =
|
||||
EdgeInsetsDirectional.fromSTEB(12.0, 12.0, 12.0, 4.0);
|
||||
EdgeInsetsDirectional.fromSTEB(14.0, 12.0, 14.0, 8.0);
|
||||
|
||||
static const actionBarHeight = 28.0;
|
||||
static const actionBarButtonSize = 24.0;
|
||||
static const actionBarHeight = 32.0;
|
||||
static const actionBarPadding = EdgeInsetsDirectional.fromSTEB(8, 0, 8, 4);
|
||||
static const actionBarButtonSize = 28.0;
|
||||
static const actionBarIconSize = 16.0;
|
||||
static const actionBarButtonSpacing = 4.0;
|
||||
static const sendButtonSize = 20.0;
|
||||
static const sendButtonSize = 24.0;
|
||||
}
|
||||
|
||||
class MobileAIPromptSizes {
|
||||
@@ -60,7 +61,11 @@ class DesktopAIConvoSizes {
|
||||
|
||||
static const avatarAndChatBubbleSpacing = 12.0;
|
||||
|
||||
static const actionBarIconSize = 24.0;
|
||||
static const actionBarIconSize = 28.0;
|
||||
static const actionBarIconSpacing = 8.0;
|
||||
static const hoverActionBarPadding = EdgeInsets.all(2.0);
|
||||
static const hoverActionBarRadius = BorderRadius.all(Radius.circular(8.0));
|
||||
static const hoverActionBarIconRadius =
|
||||
BorderRadius.all(Radius.circular(6.0));
|
||||
static const actionBarIconRadius = BorderRadius.all(Radius.circular(8.0));
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/prelude.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
|
||||
import '../chat_input/select_sources_menu.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'message_util.dart';
|
||||
|
||||
class AIMessageActionBar extends StatelessWidget {
|
||||
const AIMessageActionBar({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.showDecoration,
|
||||
this.onRegenerate,
|
||||
this.onOverrideVisibility,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final bool showDecoration;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(bool)? onOverrideVisibility;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLightMode = Theme.of(context).isLightMode;
|
||||
|
||||
final child = SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () =>
|
||||
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
|
||||
children: _buildChildren(),
|
||||
);
|
||||
|
||||
return showDecoration
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DesktopAIConvoSizes.hoverActionBarRadius,
|
||||
border: Border.all(
|
||||
color: isLightMode
|
||||
? const Color(0x1F1F2329)
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
spreadRadius: -2,
|
||||
color: isLightMode
|
||||
? const Color(0x051F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.02),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: isLightMode
|
||||
? const Color(0x051F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.02),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
color: isLightMode
|
||||
? const Color(0x051F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.02),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
List<Widget> _buildChildren() {
|
||||
return [
|
||||
CopyButton(
|
||||
isInHoverBar: showDecoration,
|
||||
textMessage: message as TextMessage,
|
||||
),
|
||||
RegenerateButton(
|
||||
isInHoverBar: showDecoration,
|
||||
onTap: () => onRegenerate?.call(),
|
||||
),
|
||||
SaveToPageButton(
|
||||
textMessage: message as TextMessage,
|
||||
isInHoverBar: showDecoration,
|
||||
onOverrideVisibility: onOverrideVisibility,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class CopyButton extends StatelessWidget {
|
||||
const CopyButton({
|
||||
super.key,
|
||||
required this.isInHoverBar,
|
||||
required this.textMessage,
|
||||
});
|
||||
|
||||
final bool isInHoverBar;
|
||||
final TextMessage textMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.settings_menu_clickToCopy.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: isInHoverBar
|
||||
? DesktopAIConvoSizes.hoverActionBarIconRadius
|
||||
: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.copy_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
onPressed: () async {
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: textMessage.text,
|
||||
inAppJson: jsonEncode(document.toJson()),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RegenerateButton extends StatelessWidget {
|
||||
const RegenerateButton({
|
||||
super.key,
|
||||
required this.isInHoverBar,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isInHoverBar;
|
||||
final void Function() onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_regenerate.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: isInHoverBar
|
||||
? DesktopAIConvoSizes.hoverActionBarIconRadius
|
||||
: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_undo_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
onPressed: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SaveToPageButton extends StatefulWidget {
|
||||
const SaveToPageButton({
|
||||
super.key,
|
||||
required this.textMessage,
|
||||
required this.isInHoverBar,
|
||||
this.onOverrideVisibility,
|
||||
});
|
||||
|
||||
final TextMessage textMessage;
|
||||
final bool isInHoverBar;
|
||||
final void Function(bool)? onOverrideVisibility;
|
||||
|
||||
@override
|
||||
State<SaveToPageButton> createState() => _SaveToPageButtonState();
|
||||
}
|
||||
|
||||
class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
|
||||
final userProfile = userWorkspaceBloc.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(
|
||||
create: (context) => ChatSettingsCubit(),
|
||||
),
|
||||
],
|
||||
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
|
||||
selector: (state) => state.currentSpace,
|
||||
builder: (context, spaceView) {
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
margin: EdgeInsets.zero,
|
||||
offset: const Offset(8, 0),
|
||||
direction: PopoverDirection.rightWithBottomAligned,
|
||||
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
|
||||
onClose: () {
|
||||
if (spaceView != null) {
|
||||
context.read<ChatSettingsCubit>().refreshSources([spaceView]);
|
||||
}
|
||||
widget.onOverrideVisibility?.call(false);
|
||||
},
|
||||
child: buildButton(context, spaceView),
|
||||
popupBuilder: (_) => buildPopover(context),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildButton(BuildContext context, ViewPB? spaceView) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_addToPageButton.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: widget.isInHoverBar
|
||||
? DesktopAIConvoSizes.hoverActionBarIconRadius
|
||||
: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_add_to_page_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
onPressed: () async {
|
||||
final documentId = getOpenedDocumentId();
|
||||
if (documentId != null) {
|
||||
await onAddToExistingPage(context, documentId);
|
||||
await forceReloadAndUpdateSelection(documentId);
|
||||
} else {
|
||||
widget.onOverrideVisibility?.call(true);
|
||||
if (spaceView != null) {
|
||||
context.read<ChatSettingsCubit>().refreshSources([spaceView]);
|
||||
}
|
||||
popoverController.show();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPopover(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: _SaveToPagePopoverContent(
|
||||
onAddToNewPage: () {
|
||||
addMessageToNewPage(context);
|
||||
popoverController.close();
|
||||
},
|
||||
onAddToExistingPage: (documentId) async {
|
||||
popoverController.close();
|
||||
final view = await onAddToExistingPage(context, documentId);
|
||||
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, view);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ViewPB?> onAddToExistingPage(
|
||||
BuildContext context,
|
||||
String documentId,
|
||||
) async {
|
||||
await ChatEditDocumentService.addMessageToPage(
|
||||
documentId,
|
||||
widget.textMessage,
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final view = await ViewBackendService.getView(documentId).toNullable();
|
||||
if (context.mounted) {
|
||||
showSaveMessageSuccessToast(context, view);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
void addMessageToNewPage(BuildContext context) async {
|
||||
final chatView = await ViewBackendService.getView(
|
||||
context.read<ChatAIMessageBloc>().chatId,
|
||||
).toNullable();
|
||||
if (chatView != null) {
|
||||
final newView = await ChatEditDocumentService.saveMessagesToNewPage(
|
||||
chatView.nameOrDefault,
|
||||
chatView.parentViewId,
|
||||
[widget.textMessage],
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
showSaveMessageSuccessToast(context, newView);
|
||||
openPageFromMessage(context, newView);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
|
||||
if (view == null) {
|
||||
return;
|
||||
}
|
||||
showToastNotification(
|
||||
context,
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: LocaleKeys.chat_addToNewPageSuccessToast.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFFFFFFFF),
|
||||
),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' ',
|
||||
),
|
||||
TextSpan(
|
||||
text: view.nameOrDefault,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: const Color(0xFFFFFFFF),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> forceReloadAndUpdateSelection(String documentId) async {
|
||||
final bloc = DocumentBloc.findOpen(documentId);
|
||||
if (bloc == null) {
|
||||
return;
|
||||
}
|
||||
await bloc.forceReloadDocumentState();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final editorState = bloc.state.editorState;
|
||||
final lastNodePath = editorState?.getLastSelectable()?.$1.path;
|
||||
if (editorState == null || lastNodePath == null) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
editorState.updateSelectionWithReason(
|
||||
Selection.collapsed(Position(path: lastNodePath)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? getOpenedDocumentId() {
|
||||
final pageManager = getIt<TabsBloc>().state.currentPageManager;
|
||||
if (!pageManager.showSecondaryPluginNotifier.value) {
|
||||
return null;
|
||||
}
|
||||
return pageManager.secondaryNotifier.plugin.id;
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveToPagePopoverContent extends StatelessWidget {
|
||||
const _SaveToPagePopoverContent({
|
||||
required this.onAddToNewPage,
|
||||
required this.onAddToExistingPage,
|
||||
});
|
||||
|
||||
final void Function() onAddToNewPage;
|
||||
final void Function(String) onAddToExistingPage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 24,
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_addToPageTitle.tr(),
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
|
||||
child: SpaceSearchField(
|
||||
width: 600,
|
||||
onSearch: (context, value) =>
|
||||
context.read<ChatSettingsCubit>().updateFilter(value),
|
||||
),
|
||||
),
|
||||
_buildDivider(),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
|
||||
children: _buildVisibleSources(context, state).toList(),
|
||||
),
|
||||
),
|
||||
_buildDivider(),
|
||||
_addToNewPageButton(context),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
return const Divider(
|
||||
height: 1.0,
|
||||
thickness: 1.0,
|
||||
indent: 12.0,
|
||||
endIndent: 12.0,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> _buildVisibleSources(
|
||||
BuildContext context,
|
||||
ChatSettingsState state,
|
||||
) {
|
||||
return state.visibleSources
|
||||
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
|
||||
.map(
|
||||
(e) => ChatSourceTreeItem(
|
||||
key: ValueKey(
|
||||
'save_to_page_tree_item_${e.view.id}',
|
||||
),
|
||||
chatSource: e,
|
||||
level: 0,
|
||||
isDescendentOfSpace: e.view.isSpace,
|
||||
isSelectedSection: false,
|
||||
showCheckbox: false,
|
||||
onSelected: (source) {
|
||||
if (!source.view.isSpace) {
|
||||
onAddToExistingPage(source.view.id);
|
||||
}
|
||||
},
|
||||
height: 30.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _addToNewPageButton(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: SizedBox(
|
||||
height: 30,
|
||||
child: FlowyButton(
|
||||
iconPadding: 8,
|
||||
onTap: onAddToNewPage,
|
||||
text: FlowyText(
|
||||
LocaleKeys.chat_addToNewPage.tr(),
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.add_m,
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+114
-148
@@ -4,20 +4,25 @@ 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/widgets/flowy_mobile_quick_action_button.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../chat_avatar.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_message_action_bar.dart';
|
||||
import 'message_util.dart';
|
||||
|
||||
/// Wraps an AI response message with the avatar and actions. On desktop,
|
||||
/// the actions will be displayed below the response if the response is the
|
||||
@@ -30,12 +35,14 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
required this.child,
|
||||
required this.showActions,
|
||||
this.isLastMessage = false,
|
||||
this.onRegenerate,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
final bool showActions;
|
||||
final bool isLastMessage;
|
||||
final void Function()? onRegenerate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -60,6 +67,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
Widget _wrapBottomActions(Widget child) {
|
||||
return ChatAIBottomInlineActions(
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -67,6 +75,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
Widget _wrapHover(Widget child) {
|
||||
return ChatAIMessageHover(
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -74,6 +83,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
Widget _wrapPopMenu(Widget child) {
|
||||
return ChatAIMessagePopup(
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -84,10 +94,12 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -101,13 +113,10 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
start: DesktopAIConvoSizes.avatarSize +
|
||||
DesktopAIConvoSizes.avatarAndChatBubbleSpacing,
|
||||
),
|
||||
child: AIResponseActionBar(
|
||||
child: AIMessageActionBar(
|
||||
message: message,
|
||||
showDecoration: false,
|
||||
children: [
|
||||
CopyButton(
|
||||
textMessage: message as TextMessage,
|
||||
),
|
||||
],
|
||||
onRegenerate: onRegenerate,
|
||||
),
|
||||
),
|
||||
const VSpace(32.0),
|
||||
@@ -121,10 +130,12 @@ class ChatAIMessageHover extends StatefulWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
|
||||
@override
|
||||
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
|
||||
@@ -136,6 +147,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
|
||||
bool hoverBubble = false;
|
||||
bool hoverActionBar = false;
|
||||
bool overrideVisibility = false;
|
||||
|
||||
ScrollPosition? scrollPosition;
|
||||
|
||||
@@ -194,19 +206,20 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 784,
|
||||
maxHeight: 28,
|
||||
maxHeight: DesktopAIConvoSizes.actionBarIconSize +
|
||||
DesktopAIConvoSizes.hoverActionBarPadding.vertical,
|
||||
),
|
||||
alignment: Alignment.topLeft,
|
||||
child: hoverBubble || hoverActionBar
|
||||
? AIResponseActionBar(
|
||||
child: hoverBubble || hoverActionBar || overrideVisibility
|
||||
? AIMessageActionBar(
|
||||
message: widget.message,
|
||||
showDecoration: true,
|
||||
children: [
|
||||
CopyButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
),
|
||||
],
|
||||
onRegenerate: widget.onRegenerate,
|
||||
onOverrideVisibility: (visibility) {
|
||||
overrideVisibility = visibility;
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -255,7 +268,10 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
final messageOffset = messageRenderBox.localToGlobal(Offset.zero);
|
||||
final messageHeight = messageRenderBox.size.height;
|
||||
|
||||
return messageOffset.dy + messageHeight + 28 <=
|
||||
return messageOffset.dy +
|
||||
messageHeight +
|
||||
DesktopAIConvoSizes.actionBarIconSize +
|
||||
DesktopAIConvoSizes.hoverActionBarPadding.vertical <=
|
||||
scrollableOffset.dy + scrollableHeight;
|
||||
}
|
||||
|
||||
@@ -266,119 +282,17 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
}
|
||||
}
|
||||
|
||||
class AIResponseActionBar extends StatelessWidget {
|
||||
const AIResponseActionBar({
|
||||
super.key,
|
||||
required this.showDecoration,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final bool showDecoration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLightMode = Theme.of(context).isLightMode;
|
||||
|
||||
final child = SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () =>
|
||||
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
|
||||
children: children,
|
||||
);
|
||||
|
||||
return showDecoration
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(2.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
border: Border.all(
|
||||
color: isLightMode
|
||||
? const Color(0x1F1F2329)
|
||||
: Theme.of(context).dividerColor,
|
||||
),
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
spreadRadius: -2,
|
||||
color: isLightMode
|
||||
? const Color(0x051F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.02),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
color: isLightMode
|
||||
? const Color(0x051F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.02),
|
||||
),
|
||||
BoxShadow(
|
||||
offset: const Offset(0, 2),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
color: isLightMode
|
||||
? const Color(0x051F2329)
|
||||
: Theme.of(context).shadowColor.withOpacity(0.02),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
}
|
||||
|
||||
class CopyButton extends StatelessWidget {
|
||||
const CopyButton({
|
||||
super.key,
|
||||
required this.textMessage,
|
||||
});
|
||||
final TextMessage textMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.settings_menu_clickToCopy.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.copy_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
onPressed: () async {
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: textMessage.text,
|
||||
inAppJson: jsonEncode(document.toJson()),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatAIMessagePopup extends StatelessWidget {
|
||||
const ChatAIMessagePopup({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -393,33 +307,13 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
if (message is! TextMessage) {
|
||||
return;
|
||||
}
|
||||
final textMessage = message as TextMessage;
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: textMessage.text,
|
||||
inAppJson: jsonEncode(document.toJson()),
|
||||
),
|
||||
);
|
||||
if (bottomSheetContext.mounted) {
|
||||
Navigator.of(bottomSheetContext).pop();
|
||||
}
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: FlowySvgs.copy_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.button_copy.tr(),
|
||||
),
|
||||
const VSpace(16.0),
|
||||
_copyButton(context, bottomSheetContext),
|
||||
const Divider(height: 8.5, thickness: 0.5),
|
||||
_regenerateButton(context),
|
||||
const Divider(height: 8.5, thickness: 0.5),
|
||||
_saveToPageButton(context),
|
||||
const Divider(height: 8.5, thickness: 0.5),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -428,4 +322,76 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _copyButton(BuildContext context, BuildContext bottomSheetContext) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
if (message is! TextMessage) {
|
||||
return;
|
||||
}
|
||||
final textMessage = message as TextMessage;
|
||||
final document = customMarkdownToDocument(textMessage.text);
|
||||
await getIt<ClipboardService>().setData(
|
||||
ClipboardServiceData(
|
||||
plainText: textMessage.text,
|
||||
inAppJson: jsonEncode(document.toJson()),
|
||||
),
|
||||
);
|
||||
if (bottomSheetContext.mounted) {
|
||||
Navigator.of(bottomSheetContext).pop();
|
||||
}
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: FlowySvgs.copy_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.button_copy.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _regenerateButton(BuildContext context) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () {
|
||||
onRegenerate?.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: FlowySvgs.ai_undo_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.chat_regenerate.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _saveToPageButton(BuildContext context) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
final selectedView = await showPageSelectorSheet(
|
||||
context,
|
||||
filter: (view) =>
|
||||
!view.isSpace &&
|
||||
view.layout.isDocumentView &&
|
||||
view.parentViewId != view.id,
|
||||
);
|
||||
if (selectedView == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ChatEditDocumentService.addMessageToPage(
|
||||
selectedView.id,
|
||||
message as TextMessage,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
openPageFromMessage(context, selectedView);
|
||||
}
|
||||
},
|
||||
icon: FlowySvgs.ai_add_to_page_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.chat_addToPageButton.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+79
-23
@@ -1,11 +1,17 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:time/time.dart';
|
||||
|
||||
class AIMessageMetadata extends StatefulWidget {
|
||||
@@ -68,30 +74,44 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
|
||||
runSpacing: 4.0,
|
||||
children: widget.sources.map(
|
||||
(m) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 24,
|
||||
maxWidth: 240,
|
||||
),
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsets.all(4.0),
|
||||
useIntrinsicWidth: true,
|
||||
radius: BorderRadius.circular(8.0),
|
||||
text: FlowyText(
|
||||
m.name,
|
||||
fontSize: 12,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.icon_document_s,
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onTap: () {
|
||||
widget.onSelectedMetadata?.call(m);
|
||||
if (isURL(m.id)) {
|
||||
return _MetadataButton(
|
||||
name: m.id,
|
||||
onTap: () => widget.onSelectedMetadata?.call(m),
|
||||
);
|
||||
} else if (isUUID(m.id)) {
|
||||
return FutureBuilder<ViewPB?>(
|
||||
future: ViewBackendService.getView(m.id)
|
||||
.then((f) => f.toNullable()),
|
||||
builder: (context, snapshot) {
|
||||
final data = snapshot.data;
|
||||
if (!snapshot.hasData ||
|
||||
snapshot.connectionState != ConnectionState.done ||
|
||||
data == null) {
|
||||
return _MetadataButton(
|
||||
name: m.name,
|
||||
onTap: () => widget.onSelectedMetadata?.call(m),
|
||||
);
|
||||
}
|
||||
return BlocProvider(
|
||||
create: (_) => ViewBloc(view: data),
|
||||
child: BlocBuilder<ViewBloc, ViewState>(
|
||||
builder: (context, state) {
|
||||
return _MetadataButton(
|
||||
name: state.view.nameOrDefault,
|
||||
onTap: () => widget.onSelectedMetadata?.call(m),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
);
|
||||
} else {
|
||||
return _MetadataButton(
|
||||
name: m.name,
|
||||
onTap: () => widget.onSelectedMetadata?.call(m),
|
||||
);
|
||||
}
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
@@ -101,3 +121,39 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataButton extends StatelessWidget {
|
||||
const _MetadataButton({
|
||||
this.name = "",
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final void Function()? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 24,
|
||||
maxWidth: 240,
|
||||
),
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsets.all(4.0),
|
||||
useIntrinsicWidth: true,
|
||||
radius: BorderRadius.circular(8.0),
|
||||
text: FlowyText(
|
||||
name,
|
||||
fontSize: 12,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leftIcon: FlowySvg(
|
||||
FlowySvgs.icon_document_s,
|
||||
size: const Size.square(16),
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -33,7 +33,9 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
required this.chatId,
|
||||
required this.refSourceJsonString,
|
||||
this.onSelectedMetadata,
|
||||
this.onRegenerate,
|
||||
this.isLastMessage = false,
|
||||
this.isStreaming = false,
|
||||
});
|
||||
|
||||
final User user;
|
||||
@@ -45,6 +47,8 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final String chatId;
|
||||
final String? refSourceJsonString;
|
||||
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
|
||||
final void Function()? onRegenerate;
|
||||
final bool isStreaming;
|
||||
final bool isLastMessage;
|
||||
|
||||
@override
|
||||
@@ -79,7 +83,10 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
: ChatAIMessageBubble(
|
||||
message: message,
|
||||
isLastMessage: isLastMessage,
|
||||
showActions: stream == null && state.text.isNotEmpty,
|
||||
showActions: stream == null &&
|
||||
state.text.isNotEmpty &&
|
||||
!isStreaming,
|
||||
onRegenerate: onRegenerate,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -93,7 +100,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
),
|
||||
if (state.sources.isNotEmpty && !isLastMessage)
|
||||
const VSpace(16.0),
|
||||
const VSpace(8.0),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -101,11 +108,6 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
onRetry: () {
|
||||
context
|
||||
.read<ChatAIMessageBloc>()
|
||||
.add(const ChatAIMessageEvent.retry());
|
||||
},
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class _ChatErrorMessageWidgetState extends State<ChatErrorMessageWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.warning_filled_s,
|
||||
FlowySvgs.toast_error_filled_s,
|
||||
blendMode: null,
|
||||
),
|
||||
const HSpace(8.0),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/application/mobile_router.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
/// Opens a message in the right hand sidebar on desktop, and push the page
|
||||
/// on mobile
|
||||
void openPageFromMessage(BuildContext context, ViewPB? view) {
|
||||
if (view == null) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openSecondaryPlugin(
|
||||
plugin: view.plugin(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
context.pushView(view);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,23 +11,39 @@ import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
|
||||
|
||||
// use a global value to store the selected emoji to prevent reloading every time.
|
||||
EmojiData? kCachedEmojiData;
|
||||
const _kRecentEmojiCategoryId = 'Recent';
|
||||
|
||||
class EmojiPickerResult {
|
||||
EmojiPickerResult({
|
||||
required this.emojiId,
|
||||
required this.emoji,
|
||||
this.isRandom = false,
|
||||
});
|
||||
|
||||
final String emojiId;
|
||||
final String emoji;
|
||||
final bool isRandom;
|
||||
}
|
||||
|
||||
class FlowyEmojiPicker extends StatefulWidget {
|
||||
const FlowyEmojiPicker({
|
||||
super.key,
|
||||
required this.onEmojiSelected,
|
||||
this.emojiPerLine = 9,
|
||||
this.ensureFocus = false,
|
||||
});
|
||||
|
||||
final EmojiSelectedCallback onEmojiSelected;
|
||||
final ValueChanged<EmojiPickerResult> onEmojiSelected;
|
||||
final int emojiPerLine;
|
||||
final bool ensureFocus;
|
||||
|
||||
@override
|
||||
State<FlowyEmojiPicker> createState() => _FlowyEmojiPickerState();
|
||||
}
|
||||
|
||||
class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
EmojiData? emojiData;
|
||||
late EmojiData emojiData;
|
||||
bool loaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,12 +51,12 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
|
||||
// load the emoji data from cache if it's available
|
||||
if (kCachedEmojiData != null) {
|
||||
emojiData = kCachedEmojiData;
|
||||
loadEmojis(kCachedEmojiData!);
|
||||
} else {
|
||||
EmojiData.builtIn().then(
|
||||
(value) {
|
||||
kCachedEmojiData = value;
|
||||
setState(() => emojiData = value);
|
||||
loadEmojis(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -45,7 +64,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (emojiData == null) {
|
||||
if (!loaded) {
|
||||
return const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24.0,
|
||||
@@ -57,21 +76,21 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
}
|
||||
|
||||
return EmojiPicker(
|
||||
emojiData: emojiData!,
|
||||
emojiData: emojiData,
|
||||
configuration: EmojiPickerConfiguration(
|
||||
showTabs: false,
|
||||
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
|
||||
perLine: widget.emojiPerLine,
|
||||
),
|
||||
onEmojiSelected: widget.onEmojiSelected,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
headerBuilder: (context, category) {
|
||||
return FlowyEmojiHeader(
|
||||
category: category,
|
||||
onEmojiSelected: (id, emoji) {
|
||||
widget.onEmojiSelected.call(
|
||||
EmojiPickerResult(emojiId: id, emoji: emoji),
|
||||
);
|
||||
RecentIcons.putEmoji(id);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
headerBuilder: (_, category) => FlowyEmojiHeader(category: category),
|
||||
itemBuilder: (context, emojiId, emoji, callback) {
|
||||
final name = emojiData?.emojis[emojiId]?.name ?? '';
|
||||
final name = emojiData.emojis[emojiId]?.name ?? '';
|
||||
return SizedBox.square(
|
||||
dimension: 36.0,
|
||||
child: FlowyButton(
|
||||
@@ -79,6 +98,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
radius: Corners.s8Border,
|
||||
text: FlowyTooltip(
|
||||
message: name,
|
||||
preferBelow: false,
|
||||
child: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: 24.0,
|
||||
@@ -92,17 +112,43 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: FlowyEmojiSearchBar(
|
||||
emojiData: emojiData!,
|
||||
emojiData: emojiData,
|
||||
ensureFocus: widget.ensureFocus,
|
||||
onKeywordChanged: (value) {
|
||||
keyword.value = value;
|
||||
},
|
||||
onSkinToneChanged: (value) {
|
||||
skinTone.value = value;
|
||||
},
|
||||
onRandomEmojiSelected: widget.onEmojiSelected,
|
||||
onRandomEmojiSelected: (id, emoji) {
|
||||
widget.onEmojiSelected.call(
|
||||
EmojiPickerResult(emojiId: id, emoji: emoji, isRandom: true),
|
||||
);
|
||||
RecentIcons.putEmoji(id);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void loadEmojis(EmojiData data) {
|
||||
RecentIcons.getEmojiIds().then((v) {
|
||||
if (v.isEmpty) {
|
||||
emojiData = data;
|
||||
setState(() => loaded = true);
|
||||
return;
|
||||
}
|
||||
final categories = List.of(data.categories);
|
||||
categories.insert(
|
||||
0,
|
||||
Category(
|
||||
id: _kRecentEmojiCategoryId,
|
||||
emojiIds: v.sublist(0, min(widget.emojiPerLine, v.length)),
|
||||
),
|
||||
);
|
||||
emojiData = EmojiData(categories: categories, emojis: data.emojis);
|
||||
setState(() => loaded = true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,44 @@
|
||||
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class MobileEmojiPickerScreen extends StatelessWidget {
|
||||
const MobileEmojiPickerScreen({super.key, this.title});
|
||||
import '../../../generated/locale_keys.g.dart';
|
||||
import '../../../mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import '../../../shared/icon_emoji_picker/tab.dart';
|
||||
|
||||
class MobileEmojiPickerScreen extends StatelessWidget {
|
||||
const MobileEmojiPickerScreen({
|
||||
super.key,
|
||||
this.title,
|
||||
this.selectedType,
|
||||
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
|
||||
});
|
||||
|
||||
final PickerTabType? selectedType;
|
||||
final String? title;
|
||||
final List<PickerTabType> tabs;
|
||||
|
||||
static const routeName = '/emoji_picker';
|
||||
static const pageTitle = 'title';
|
||||
static const iconSelectedType = 'iconSelectedType';
|
||||
static const selectTabs = 'tabs';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconPickerPage(
|
||||
title: title,
|
||||
onSelected: (result) {
|
||||
context.pop<EmojiPickerResult>(result);
|
||||
},
|
||||
return Scaffold(
|
||||
appBar: FlowyAppBar(
|
||||
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FlowyIconEmojiPicker(
|
||||
tabs: tabs,
|
||||
initialType: selectedType,
|
||||
onSelectedEmoji: (r) {
|
||||
context.pop<EmojiIconData>(r.data);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class IconPickerPage extends StatelessWidget {
|
||||
const IconPickerPage({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final void Function(EmojiPickerResult) onSelected;
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: FlowyAppBar(
|
||||
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: FlowyIconEmojiPicker(onSelectedEmoji: onSelected),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../generated/flowy_svgs.g.dart';
|
||||
|
||||
class IconWidget extends StatelessWidget {
|
||||
const IconWidget({
|
||||
super.key,
|
||||
required this.size,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
final IconsData data;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorValue = int.tryParse(data.color ?? '');
|
||||
Color? color;
|
||||
if (colorValue != null) {
|
||||
color = Color(colorValue);
|
||||
}
|
||||
return FlowySvg.string(
|
||||
data.iconContent,
|
||||
size: Size.square(size),
|
||||
color: color,
|
||||
);
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/database/application/database_controller.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/field_info.dart';
|
||||
import 'package:appflowy/plugins/database/application/row/row_service.dart';
|
||||
import 'package:appflowy/plugins/database/grid/application/grid_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/shortcuts.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/shared/flowy_error_page.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
@@ -42,6 +41,7 @@ class MobileGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
|
||||
view: view,
|
||||
databaseController: controller,
|
||||
initialRowId: initialRowId,
|
||||
shrinkWrap: shrinkWrap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,12 +68,14 @@ class MobileGridPage extends StatefulWidget {
|
||||
required this.databaseController,
|
||||
this.onDeleted,
|
||||
this.initialRowId,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final DatabaseController databaseController;
|
||||
final VoidCallback? onDeleted;
|
||||
final String? initialRowId;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<MobileGridPage> createState() => _MobileGridPageState();
|
||||
@@ -104,7 +106,10 @@ class _MobileGridPageState extends State<MobileGridPage> {
|
||||
finish: (result) {
|
||||
_openRow(context, widget.initialRowId, true);
|
||||
return result.successOrFail.fold(
|
||||
(_) => GridShortcuts(child: GridPageContent(view: widget.view)),
|
||||
(_) => GridPageContent(
|
||||
view: widget.view,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
),
|
||||
(err) => Center(
|
||||
child: AppFlowyErrorPage(
|
||||
error: err,
|
||||
@@ -145,9 +150,11 @@ class GridPageContent extends StatefulWidget {
|
||||
const GridPageContent({
|
||||
super.key,
|
||||
required this.view,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
final bool shrinkWrap;
|
||||
|
||||
@override
|
||||
State<GridPageContent> createState() => _GridPageContentState();
|
||||
@@ -196,6 +203,7 @@ class _GridPageContentState extends State<GridPageContent> {
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_GridHeader(
|
||||
contentScrollController: contentScrollController,
|
||||
@@ -207,11 +215,12 @@ class _GridPageContentState extends State<GridPageContent> {
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: getGridFabs(context),
|
||||
),
|
||||
if (!widget.shrinkWrap)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: getGridFabs(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -256,7 +265,7 @@ class _GridRows extends StatelessWidget {
|
||||
buildWhen: (previous, current) => previous.fields != current.fields,
|
||||
builder: (context, state) {
|
||||
final double contentWidth = getMobileGridContentWidth(state.fields);
|
||||
return Expanded(
|
||||
return Flexible(
|
||||
child: _WrapScrollView(
|
||||
scrollController: scrollController,
|
||||
contentWidth: contentWidth,
|
||||
@@ -305,6 +314,7 @@ class _GridRows extends StatelessWidget {
|
||||
return ReorderableListView.builder(
|
||||
scrollController: scrollController.verticalController,
|
||||
buildDefaultDragHandles: false,
|
||||
shrinkWrap: true,
|
||||
proxyDecorator: (child, index, animation) => Material(
|
||||
color: Colors.transparent,
|
||||
child: child,
|
||||
|
||||
+1
-2
@@ -28,8 +28,7 @@ class RowCardContainer extends StatelessWidget {
|
||||
create: (_) => _CardContainerNotifier(),
|
||||
child: Consumer<_CardContainerNotifier>(
|
||||
builder: (context, notifier, _) {
|
||||
final shouldBuildAccessory =
|
||||
buildAccessoryWhen?.call() ?? true;
|
||||
final shouldBuildAccessory = buildAccessoryWhen?.call() ?? true;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
||||
+1
-1
@@ -89,7 +89,6 @@ class _ChecklistRowDetailCellState extends State<ChecklistRowDetailCell> {
|
||||
onTap: () {
|
||||
final bloc = context.read<ChecklistCellBloc>();
|
||||
if (bloc.state.phantomIndex == null) {
|
||||
phantomTextController.clear();
|
||||
bloc.add(
|
||||
ChecklistCellEvent.updatePhantomIndex(
|
||||
bloc.state.showIncompleteOnly
|
||||
@@ -107,6 +106,7 @@ class _ChecklistRowDetailCellState extends State<ChecklistRowDetailCell> {
|
||||
),
|
||||
);
|
||||
}
|
||||
phantomTextController.clear();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
+28
-30
@@ -32,40 +32,38 @@ class _ChecklistProgressBarState extends State<ChecklistProgressBar> {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.tasks.isNotEmpty &&
|
||||
widget.tasks.length <= widget.segmentLimit)
|
||||
...List<Widget>.generate(
|
||||
widget.tasks.length,
|
||||
(index) => Flexible(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(2)),
|
||||
color: index < numFinishedTasks
|
||||
? completedTaskColor
|
||||
: AFThemeExtension.of(context).progressBarBGColor,
|
||||
child: widget.tasks.isNotEmpty &&
|
||||
widget.tasks.length <= widget.segmentLimit
|
||||
? Row(
|
||||
children: [
|
||||
...List<Widget>.generate(
|
||||
widget.tasks.length,
|
||||
(index) => Flexible(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(2)),
|
||||
color: index < numFinishedTasks
|
||||
? completedTaskColor
|
||||
: AFThemeExtension.of(context)
|
||||
.progressBarBGColor,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
height: 4.0,
|
||||
),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
height: 4.0,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: LinearPercentIndicator(
|
||||
lineHeight: 4.0,
|
||||
percent: widget.percent,
|
||||
padding: EdgeInsets.zero,
|
||||
progressColor: completedTaskColor,
|
||||
backgroundColor:
|
||||
AFThemeExtension.of(context).progressBarBGColor,
|
||||
barRadius: const Radius.circular(2),
|
||||
),
|
||||
: LinearPercentIndicator(
|
||||
lineHeight: 4.0,
|
||||
percent: widget.percent,
|
||||
padding: EdgeInsets.zero,
|
||||
progressColor: completedTaskColor,
|
||||
backgroundColor:
|
||||
AFThemeExtension.of(context).progressBarBGColor,
|
||||
barRadius: const Radius.circular(2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 45,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'field_type_list.dart';
|
||||
import 'type_option_editor/builder.dart';
|
||||
|
||||
@@ -603,16 +606,22 @@ class _FieldEditIconButtonState extends State<FieldEditIconButton> {
|
||||
return FlowyIconEmojiPicker(
|
||||
enableBackgroundColorSelection: false,
|
||||
tabs: const [PickerTabType.icon],
|
||||
onSelectedIcon: (group, icon, _) {
|
||||
String newIcon = "";
|
||||
if (group != null && icon != null) {
|
||||
newIcon = '${group.name}/${icon.name}';
|
||||
onSelectedEmoji: (r) {
|
||||
if (r.type == FlowyIconType.icon) {
|
||||
try {
|
||||
final iconsData = IconsData.fromJson(jsonDecode(r.emoji));
|
||||
context.read<FieldEditorBloc>().add(
|
||||
FieldEditorEvent.updateIcon(
|
||||
'${iconsData.groupName}/${iconsData.iconName}',
|
||||
),
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
Log.warn('FieldEditIconButton onSelectedEmoji error:$e');
|
||||
context
|
||||
.read<FieldEditorBloc>()
|
||||
.add(const FieldEditorEvent.updateIcon(''));
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
.read<FieldEditorBloc>()
|
||||
.add(FieldEditorEvent.updateIcon(newIcon));
|
||||
|
||||
PopoverContainer.of(popoverContext).close();
|
||||
},
|
||||
);
|
||||
|
||||
+5
-2
@@ -1,3 +1,4 @@
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
@@ -8,7 +9,6 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/size.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
import '../../cell/editable_cell_builder.dart';
|
||||
@@ -188,6 +188,9 @@ class CellAccessoryContainer extends StatelessWidget {
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return Wrap(spacing: 6, children: children);
|
||||
return SeparatedRow(
|
||||
separatorBuilder: () => const HSpace(6),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../../../shared/icon_emoji_picker/tab.dart';
|
||||
import '../../../document/presentation/editor_plugins/plugins.dart';
|
||||
|
||||
/// We have the cover height as public as it is used in the row_detail.dart file
|
||||
@@ -151,7 +152,11 @@ class _RowBannerState extends State<RowBanner> {
|
||||
? _toolbarHeight - _iconHeight / 2
|
||||
: _toolbarHeight,
|
||||
child: RowIcon(
|
||||
icon: state.rowMeta.icon,
|
||||
///TODO: avoid hardcoding for [FlowyIconType]
|
||||
icon: EmojiIconData(
|
||||
FlowyIconType.emoji,
|
||||
state.rowMeta.icon,
|
||||
),
|
||||
onIconChanged: (icon) {
|
||||
if (icon == null || icon.isEmpty) {
|
||||
context
|
||||
@@ -497,6 +502,7 @@ class _RowHeaderToolbarState extends State<RowHeaderToolbar> {
|
||||
popupBuilder: (_) {
|
||||
isPopoverOpen = true;
|
||||
return FlowyIconEmojiPicker(
|
||||
tabs: const [PickerTabType.emoji],
|
||||
onSelectedEmoji: (result) {
|
||||
widget.onIconChanged(result.emoji);
|
||||
popoverController.close();
|
||||
@@ -514,7 +520,7 @@ class _RowHeaderToolbarState extends State<RowHeaderToolbar> {
|
||||
),
|
||||
onTap: () async {
|
||||
if (!isDesktop) {
|
||||
final result = await context.push<EmojiPickerResult>(
|
||||
final result = await context.push<EmojiIconData>(
|
||||
MobileEmojiPickerScreen.routeName,
|
||||
);
|
||||
|
||||
@@ -543,7 +549,7 @@ class RowIcon extends StatefulWidget {
|
||||
required this.onIconChanged,
|
||||
});
|
||||
|
||||
final String icon;
|
||||
final EmojiIconData icon;
|
||||
final void Function(String?) onIconChanged;
|
||||
|
||||
@override
|
||||
@@ -566,6 +572,7 @@ class _RowIconState extends State<RowIcon> {
|
||||
constraints: BoxConstraints.loose(const Size(360, 380)),
|
||||
margin: EdgeInsets.zero,
|
||||
popupBuilder: (_) => FlowyIconEmojiPicker(
|
||||
tabs: const [PickerTabType.emoji],
|
||||
onSelectedEmoji: (result) {
|
||||
controller.close();
|
||||
widget.onIconChanged(result.emoji);
|
||||
|
||||
+11
-13
@@ -6,6 +6,7 @@ import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dar
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -14,6 +15,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'database_document_title_bloc.dart';
|
||||
|
||||
// This widget is largely copied from `workspace/presentation/widgets/view_title_bar.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department.
|
||||
@@ -163,8 +165,8 @@ class _TitleSkin extends IEditableTextCellSkin {
|
||||
popupBuilder: (_) {
|
||||
return RenameRowPopover(
|
||||
textController: textEditingController,
|
||||
icon: state.icon ?? "",
|
||||
onUpdateIcon: (String icon) {
|
||||
icon: state.icon ?? EmojiIconData.none(),
|
||||
onUpdateIcon: (icon) {
|
||||
context
|
||||
.read<DatabaseDocumentTitleBloc>()
|
||||
.add(DatabaseDocumentTitleEvent.updateIcon(icon));
|
||||
@@ -179,11 +181,7 @@ class _TitleSkin extends IEditableTextCellSkin {
|
||||
text: Row(
|
||||
children: [
|
||||
if (state.icon != null) ...[
|
||||
FlowyText.emoji(
|
||||
state.icon!,
|
||||
fontSize: 14.0,
|
||||
figmaLineHeight: 18.0,
|
||||
),
|
||||
RawEmojiIconWidget(emoji: state.icon!, emojiSize: 14),
|
||||
const HSpace(4.0),
|
||||
],
|
||||
ConstrainedBox(
|
||||
@@ -217,10 +215,10 @@ class RenameRowPopover extends StatefulWidget {
|
||||
});
|
||||
|
||||
final TextEditingController textController;
|
||||
final String icon;
|
||||
final EmojiIconData icon;
|
||||
|
||||
final void Function(String name) onUpdateName;
|
||||
final void Function(String icon) onUpdateIcon;
|
||||
final ValueChanged<String> onUpdateName;
|
||||
final ValueChanged<EmojiIconData> onUpdateIcon;
|
||||
|
||||
@override
|
||||
State<RenameRowPopover> createState() => _RenameRowPopoverState();
|
||||
@@ -246,9 +244,9 @@ class _RenameRowPopoverState extends State<RenameRowPopover> {
|
||||
direction: PopoverDirection.bottomWithCenterAligned,
|
||||
offset: const Offset(0, 18),
|
||||
defaultIcon: const FlowySvg(FlowySvgs.document_s),
|
||||
onSubmitted: (emoji, _) {
|
||||
widget.onUpdateIcon(emoji);
|
||||
PopoverContainer.of(context).close();
|
||||
onSubmitted: (r, _) {
|
||||
widget.onUpdateIcon(r.data);
|
||||
if (!r.keepOpen) PopoverContainer.of(context).close();
|
||||
},
|
||||
),
|
||||
const HSpace(6),
|
||||
|
||||
+19
-6
@@ -12,6 +12,8 @@ import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
|
||||
part 'database_document_title_bloc.freezed.dart';
|
||||
|
||||
class DatabaseDocumentTitleBloc
|
||||
@@ -57,7 +59,7 @@ class DatabaseDocumentTitleBloc
|
||||
);
|
||||
},
|
||||
updateIcon: (icon) {
|
||||
_updateMeta(icon);
|
||||
_updateMeta(icon.emoji);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -67,7 +69,11 @@ class DatabaseDocumentTitleBloc
|
||||
_metaListener.start(
|
||||
callback: (rowMeta) {
|
||||
if (!isClosed) {
|
||||
add(DatabaseDocumentTitleEvent.didUpdateRowIcon(rowMeta.icon));
|
||||
add(
|
||||
DatabaseDocumentTitleEvent.didUpdateRowIcon(
|
||||
EmojiIconData.emoji(rowMeta.icon),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -116,7 +122,11 @@ class DatabaseDocumentTitleBloc
|
||||
|
||||
// initialize icon
|
||||
if (rowInfo.rowMeta.icon.isNotEmpty) {
|
||||
add(DatabaseDocumentTitleEvent.didUpdateRowIcon(rowInfo.rowMeta.icon));
|
||||
add(
|
||||
DatabaseDocumentTitleEvent.didUpdateRowIcon(
|
||||
EmojiIconData.emoji(rowInfo.rowMeta.icon),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,16 +146,19 @@ class DatabaseDocumentTitleEvent with _$DatabaseDocumentTitleEvent {
|
||||
const factory DatabaseDocumentTitleEvent.didUpdateAncestors(
|
||||
List<ViewPB> ancestors,
|
||||
) = _DidUpdateAncestors;
|
||||
|
||||
const factory DatabaseDocumentTitleEvent.didUpdateRowTitleInfo(
|
||||
DatabaseController databaseController,
|
||||
RowController rowController,
|
||||
String fieldId,
|
||||
) = _DidUpdateRowTitleInfo;
|
||||
|
||||
const factory DatabaseDocumentTitleEvent.didUpdateRowIcon(
|
||||
String icon,
|
||||
EmojiIconData icon,
|
||||
) = _DidUpdateRowIcon;
|
||||
|
||||
const factory DatabaseDocumentTitleEvent.updateIcon(
|
||||
String icon,
|
||||
EmojiIconData icon,
|
||||
) = _UpdateIcon;
|
||||
}
|
||||
|
||||
@@ -156,7 +169,7 @@ class DatabaseDocumentTitleState with _$DatabaseDocumentTitleState {
|
||||
required DatabaseController? databaseController,
|
||||
required RowController? rowController,
|
||||
required String? fieldId,
|
||||
required String? icon,
|
||||
required EmojiIconData? icon,
|
||||
}) = _DatabaseDocumentTitleState;
|
||||
|
||||
factory DatabaseDocumentTitleState.initial() =>
|
||||
|
||||
@@ -40,12 +40,16 @@ part 'document_bloc.freezed.dart';
|
||||
|
||||
bool enableDocumentInternalLog = false;
|
||||
|
||||
final Map<String, DocumentBloc> _documentBlocMap = {};
|
||||
|
||||
class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
DocumentBloc({
|
||||
required this.documentId,
|
||||
this.databaseViewId,
|
||||
this.rowId,
|
||||
}) : _documentListener = DocumentListener(id: documentId),
|
||||
bool saveToBlocMap = true,
|
||||
}) : _saveToBlocMap = saveToBlocMap,
|
||||
_documentListener = DocumentListener(id: documentId),
|
||||
_syncStateListener = DocumentSyncStateListener(id: documentId),
|
||||
super(DocumentState.initial()) {
|
||||
_viewListener = databaseViewId == null && rowId == null
|
||||
@@ -54,12 +58,17 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
on<DocumentEvent>(_onDocumentEvent);
|
||||
}
|
||||
|
||||
static DocumentBloc? findOpen(String documentId) =>
|
||||
_documentBlocMap[documentId];
|
||||
|
||||
/// For a normal document, the document id is the same as the view id
|
||||
final String documentId;
|
||||
|
||||
final String? databaseViewId;
|
||||
final String? rowId;
|
||||
|
||||
final bool _saveToBlocMap;
|
||||
|
||||
final DocumentListener _documentListener;
|
||||
final DocumentSyncStateListener _syncStateListener;
|
||||
late final ViewListener? _viewListener;
|
||||
@@ -95,6 +104,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
@override
|
||||
Future<void> close() async {
|
||||
isClosing = true;
|
||||
if (_saveToBlocMap) {
|
||||
_documentBlocMap.remove(documentId);
|
||||
}
|
||||
await checkDocumentIntegrity();
|
||||
await _cancelSubscriptions();
|
||||
_clearEditorState();
|
||||
@@ -128,6 +140,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
) async {
|
||||
await event.when(
|
||||
initial: () async {
|
||||
if (_saveToBlocMap) {
|
||||
_documentBlocMap[documentId] = this;
|
||||
}
|
||||
final result = await _fetchDocumentState();
|
||||
_onViewChanged();
|
||||
_onDocumentChanged();
|
||||
@@ -407,6 +422,10 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> forceReloadDocumentState() {
|
||||
return _documentCollabAdapter.syncV3();
|
||||
}
|
||||
|
||||
// this is only used for debug mode
|
||||
Future<void> checkDocumentIntegrity() async {
|
||||
if (!enableDocumentInternalLog) {
|
||||
|
||||
+5
-1
@@ -85,7 +85,11 @@ class DocumentCollaboratorsBloc
|
||||
final ids = <dynamic>{};
|
||||
final sorted = states.value.values.toList()
|
||||
..sort((a, b) => b.timestamp.compareTo(a.timestamp))
|
||||
..retainWhere((e) => ids.add(e.user.uid.toString() + e.user.deviceId));
|
||||
// filter the duplicate users
|
||||
..retainWhere((e) => ids.add(e.user.uid.toString() + e.user.deviceId))
|
||||
// only keep version 1 and metadata is not empty
|
||||
..retainWhere((e) => e.version == 1)
|
||||
..retainWhere((e) => e.metadata.isNotEmpty);
|
||||
for (final state in sorted) {
|
||||
if (state.version != 1) {
|
||||
continue;
|
||||
|
||||
@@ -11,7 +11,7 @@ class DocumentValidator {
|
||||
|
||||
Future<bool> validate(Transaction transaction) async {
|
||||
// deep copy the document
|
||||
final root = this.editorState.document.root.copyWith();
|
||||
final root = this.editorState.document.root.deepCopy();
|
||||
final dummyDocument = Document(root: root);
|
||||
if (dummyDocument.isEmpty) {
|
||||
return true;
|
||||
@@ -38,7 +38,7 @@ class DocumentValidator {
|
||||
|
||||
Future<bool> applyTransactionInDummyDocument(Transaction transaction) async {
|
||||
// deep copy the document
|
||||
final root = this.editorState.document.root.copyWith();
|
||||
final root = this.editorState.document.root.deepCopy();
|
||||
final dummyDocument = Document(root: root);
|
||||
if (dummyDocument.isEmpty) {
|
||||
return true;
|
||||
|
||||
+81
-37
@@ -7,24 +7,11 @@ import 'package:appflowy/plugins/document/application/document_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_block_component.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart'
|
||||
show
|
||||
EditorState,
|
||||
Transaction,
|
||||
Operation,
|
||||
InsertOperation,
|
||||
UpdateOperation,
|
||||
DeleteOperation,
|
||||
PathExtensions,
|
||||
Node,
|
||||
Path,
|
||||
Delta,
|
||||
composeAttributes,
|
||||
blockComponentDelta;
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:nanoid/nanoid.dart';
|
||||
|
||||
const _kExternalTextType = 'text';
|
||||
const kExternalTextType = 'text';
|
||||
|
||||
/// Uses to adjust the data structure between the editor and the backend.
|
||||
///
|
||||
@@ -183,17 +170,16 @@ extension on InsertOperation {
|
||||
final parentId = node.parent?.id ??
|
||||
editorState.getNodeAtPath(currentPath.parent)?.id ??
|
||||
'';
|
||||
var prevId = previousNode?.id;
|
||||
assert(parentId.isNotEmpty);
|
||||
|
||||
String prevId = '';
|
||||
// if the node is the first child of the parent, then its prevId should be empty.
|
||||
final isFirstChild = currentPath.previous.equals(currentPath);
|
||||
|
||||
if (!isFirstChild) {
|
||||
prevId ??= editorState.getNodeAtPath(currentPath.previous)?.id ?? '';
|
||||
}
|
||||
prevId ??= '';
|
||||
assert(parentId.isNotEmpty);
|
||||
if (isFirstChild) {
|
||||
prevId = '';
|
||||
} else {
|
||||
prevId = previousNode?.id ??
|
||||
editorState.getNodeAtPath(currentPath.previous)?.id ??
|
||||
'';
|
||||
assert(prevId.isNotEmpty && prevId != node.id);
|
||||
}
|
||||
|
||||
@@ -213,7 +199,7 @@ extension on InsertOperation {
|
||||
// sync the text id to the node
|
||||
node.externalValues = ExternalValues(
|
||||
externalId: textId,
|
||||
externalType: _kExternalTextType,
|
||||
externalType: kExternalTextType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,7 +208,7 @@ extension on InsertOperation {
|
||||
..block = node.toBlock(
|
||||
childrenId: nanoid(6),
|
||||
externalId: textId,
|
||||
externalType: textId != null ? _kExternalTextType : null,
|
||||
externalType: textId != null ? kExternalTextType : null,
|
||||
attributes: {...node.attributes}..remove(blockComponentDelta),
|
||||
)
|
||||
..parentId = parentId
|
||||
@@ -288,11 +274,6 @@ extension on UpdateOperation {
|
||||
// create the external text if the node contains the delta in its data.
|
||||
final prevDelta = oldAttributes[blockComponentDelta];
|
||||
final delta = attributes[blockComponentDelta];
|
||||
final diff = prevDelta != null && delta != null
|
||||
? Delta.fromJson(prevDelta).diff(
|
||||
Delta.fromJson(delta),
|
||||
)
|
||||
: null;
|
||||
|
||||
final composedAttributes = composeAttributes(oldAttributes, attributes);
|
||||
final composedDelta = composedAttributes?[blockComponentDelta];
|
||||
@@ -313,17 +294,20 @@ extension on UpdateOperation {
|
||||
// to be compatible with the old version, we create a new text id if the text id is empty.
|
||||
final textId = nanoid(6);
|
||||
final textDelta = composedDelta ?? delta ?? prevDelta;
|
||||
final textDeltaPayloadPB = textDelta == null
|
||||
final correctedTextDelta =
|
||||
textDelta != null ? _correctAttributes(textDelta) : null;
|
||||
|
||||
final textDeltaPayloadPB = correctedTextDelta == null
|
||||
? null
|
||||
: TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: jsonEncode(textDelta),
|
||||
delta: jsonEncode(correctedTextDelta),
|
||||
);
|
||||
|
||||
node.externalValues = ExternalValues(
|
||||
externalId: textId,
|
||||
externalType: _kExternalTextType,
|
||||
externalType: kExternalTextType,
|
||||
);
|
||||
|
||||
if (enableDocumentInternalLog) {
|
||||
@@ -333,7 +317,7 @@ extension on UpdateOperation {
|
||||
// update the external text id and external type to the block
|
||||
blockActionPB.payload.block
|
||||
..externalId = textId
|
||||
..externalType = _kExternalTextType;
|
||||
..externalType = kExternalTextType;
|
||||
|
||||
actions.add(
|
||||
BlockActionWrapper(
|
||||
@@ -343,12 +327,20 @@ extension on UpdateOperation {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final textDeltaPayloadPB = delta == null
|
||||
final diff = prevDelta != null && delta != null
|
||||
? Delta.fromJson(prevDelta).diff(
|
||||
Delta.fromJson(delta),
|
||||
)
|
||||
: null;
|
||||
|
||||
final correctedDiff = diff != null ? _correctDelta(diff) : null;
|
||||
|
||||
final textDeltaPayloadPB = correctedDiff == null
|
||||
? null
|
||||
: TextDeltaPayloadPB(
|
||||
documentId: documentId,
|
||||
textId: textId,
|
||||
delta: jsonEncode(diff),
|
||||
delta: jsonEncode(correctedDiff),
|
||||
);
|
||||
|
||||
if (enableDocumentInternalLog) {
|
||||
@@ -358,7 +350,7 @@ extension on UpdateOperation {
|
||||
// update the external text id and external type to the block
|
||||
blockActionPB.payload.block
|
||||
..externalId = textId
|
||||
..externalType = _kExternalTextType;
|
||||
..externalType = kExternalTextType;
|
||||
|
||||
actions.add(
|
||||
BlockActionWrapper(
|
||||
@@ -371,6 +363,58 @@ extension on UpdateOperation {
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
// if the value in Delta's attributes is false, we should set the value to null instead.
|
||||
// because on Yjs, canceling format must use the null value. If we use false, the update will be rejected.
|
||||
List<TextOperation>? _correctDelta(Delta delta) {
|
||||
// if the value in diff's attributes is false, we should set the value to null instead.
|
||||
// because on Yjs, canceling format must use the null value. If we use false, the update will be rejected.
|
||||
final correctedOps = delta.map((op) {
|
||||
final attributes = op.attributes?.map(
|
||||
(key, value) => MapEntry(
|
||||
key,
|
||||
// if the value is false, we should set the value to null instead.
|
||||
value == false ? null : value,
|
||||
),
|
||||
);
|
||||
|
||||
if (attributes != null) {
|
||||
if (op is TextRetain) {
|
||||
return TextRetain(op.length, attributes: attributes);
|
||||
} else if (op is TextInsert) {
|
||||
return TextInsert(op.text, attributes: attributes);
|
||||
}
|
||||
// ignore the other operations that do not contain attributes.
|
||||
}
|
||||
|
||||
return op;
|
||||
});
|
||||
|
||||
return correctedOps.toList(growable: false);
|
||||
}
|
||||
|
||||
// Refer to [_correctDelta] for more details.
|
||||
List<Map<String, dynamic>> _correctAttributes(
|
||||
List<Map<String, dynamic>> attributes,
|
||||
) {
|
||||
final correctedAttributes = attributes.map((attribute) {
|
||||
return attribute.map((key, value) {
|
||||
if (value is bool) {
|
||||
return MapEntry(key, value == false ? null : value);
|
||||
} else if (value is Map<String, dynamic>) {
|
||||
return MapEntry(
|
||||
key,
|
||||
value.map((key, value) {
|
||||
return MapEntry(key, value == false ? null : value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return MapEntry(key, value);
|
||||
});
|
||||
}).toList(growable: false);
|
||||
|
||||
return correctedAttributes;
|
||||
}
|
||||
}
|
||||
|
||||
extension on DeleteOperation {
|
||||
|
||||
+239
-80
@@ -28,9 +28,9 @@ final Set<String> supportSlashMenuNodeTypes = {
|
||||
ToggleListBlockKeys.type,
|
||||
|
||||
// Simple table
|
||||
// SimpleTableBlockKeys.type,
|
||||
// SimpleTableRowBlockKeys.type,
|
||||
// SimpleTableCellBlockKeys.type,
|
||||
SimpleTableBlockKeys.type,
|
||||
SimpleTableRowBlockKeys.type,
|
||||
SimpleTableCellBlockKeys.type,
|
||||
};
|
||||
|
||||
/// Build the block component builders.
|
||||
@@ -47,7 +47,7 @@ Map<String, BlockComponentBuilder> buildBlockComponentBuilders({
|
||||
required BuildContext context,
|
||||
required EditorState editorState,
|
||||
required EditorStyleCustomizer styleCustomizer,
|
||||
List<SelectionMenuItem>? slashMenuItems,
|
||||
SlashMenuItemsBuilder? slashMenuItemsBuilder,
|
||||
bool editable = true,
|
||||
ShowPlaceholder? showParagraphPlaceholder,
|
||||
String Function(Node)? placeholderText,
|
||||
@@ -70,7 +70,7 @@ Map<String, BlockComponentBuilder> buildBlockComponentBuilders({
|
||||
builders: builders,
|
||||
editorState: editorState,
|
||||
styleCustomizer: styleCustomizer,
|
||||
slashMenuItems: slashMenuItems,
|
||||
slashMenuItemsBuilder: slashMenuItemsBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,12 +79,25 @@ Map<String, BlockComponentBuilder> buildBlockComponentBuilders({
|
||||
|
||||
BlockComponentConfiguration _buildDefaultConfiguration(BuildContext context) {
|
||||
final configuration = BlockComponentConfiguration(
|
||||
padding: (_) {
|
||||
padding: (node) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
final pageStyle = context.read<DocumentPageStyleBloc>().state;
|
||||
final factor = pageStyle.fontLayout.factor;
|
||||
final padding = pageStyle.lineHeightLayout.padding * factor;
|
||||
return EdgeInsets.only(top: padding);
|
||||
final top = pageStyle.lineHeightLayout.padding * factor;
|
||||
EdgeInsets edgeInsets = EdgeInsets.only(top: top);
|
||||
// only add padding for the top level node, otherwise the nested node will have extra padding
|
||||
if (node.path.length == 1) {
|
||||
if (node.type != SimpleTableBlockKeys.type) {
|
||||
// do not add padding for the simple table to allow it overflow
|
||||
edgeInsets = edgeInsets.copyWith(
|
||||
left: EditorStyleCustomizer.nodeHorizontalPadding,
|
||||
);
|
||||
}
|
||||
edgeInsets = edgeInsets.copyWith(
|
||||
right: EditorStyleCustomizer.nodeHorizontalPadding,
|
||||
);
|
||||
}
|
||||
return edgeInsets;
|
||||
}
|
||||
|
||||
return const EdgeInsets.symmetric(vertical: 5.0);
|
||||
@@ -113,6 +126,14 @@ List<OptionAction> _buildOptionActions(BuildContext context, String type) {
|
||||
|
||||
standardActions.add(OptionAction.turnInto);
|
||||
|
||||
if (SimpleTableBlockKeys.type == type) {
|
||||
standardActions.addAll([
|
||||
OptionAction.divider,
|
||||
OptionAction.setToPageWidth,
|
||||
OptionAction.distributeColumnsEvenly,
|
||||
]);
|
||||
}
|
||||
|
||||
if (EditorOptionActionType.color.supportTypes.contains(type)) {
|
||||
standardActions.addAll([OptionAction.divider, OptionAction.color]);
|
||||
}
|
||||
@@ -125,14 +146,6 @@ List<OptionAction> _buildOptionActions(BuildContext context, String type) {
|
||||
standardActions.addAll([OptionAction.divider, OptionAction.depth]);
|
||||
}
|
||||
|
||||
if (SimpleTableBlockKeys.type == type) {
|
||||
standardActions.addAll([
|
||||
OptionAction.divider,
|
||||
OptionAction.setToPageWidth,
|
||||
OptionAction.distributeColumnsEvenly,
|
||||
]);
|
||||
}
|
||||
|
||||
return standardActions;
|
||||
}
|
||||
|
||||
@@ -141,7 +154,7 @@ void _customBlockOptionActions(
|
||||
required Map<String, BlockComponentBuilder> builders,
|
||||
required EditorState editorState,
|
||||
required EditorStyleCustomizer styleCustomizer,
|
||||
List<SelectionMenuItem>? slashMenuItems,
|
||||
SlashMenuItemsBuilder? slashMenuItemsBuilder,
|
||||
}) {
|
||||
for (final entry in builders.entries) {
|
||||
if (entry.key == PageBlockKeys.type) {
|
||||
@@ -153,6 +166,7 @@ void _customBlockOptionActions(
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
builder.showActions = (node) {
|
||||
final parentTableNode = node.parentTableNode;
|
||||
// disable the option action button in table cell to avoid the misalignment issue
|
||||
if (node.type != SimpleTableBlockKeys.type && parentTableNode != null) {
|
||||
return false;
|
||||
}
|
||||
@@ -187,9 +201,9 @@ void _customBlockOptionActions(
|
||||
editorState: editorState,
|
||||
blockComponentBuilder: builders,
|
||||
actions: actions,
|
||||
showSlashMenu: slashMenuItems != null
|
||||
? () => customSlashCommand(
|
||||
slashMenuItems,
|
||||
showSlashMenu: slashMenuItemsBuilder != null
|
||||
? () => customAppFlowySlashCommand(
|
||||
itemsBuilder: slashMenuItemsBuilder,
|
||||
shouldInsertSlash: false,
|
||||
deleteKeywordsByDefault: true,
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
@@ -318,6 +332,7 @@ Map<String, BlockComponentBuilder> _buildBlockComponentBuilderMap(
|
||||
SubPageBlockKeys.type: _buildSubPageBlockComponentBuilder(
|
||||
context,
|
||||
configuration,
|
||||
styleCustomizer: styleCustomizer,
|
||||
),
|
||||
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
@@ -354,7 +369,7 @@ SimpleTableBlockComponentBuilder _buildSimpleTableBlockComponentBuilder(
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
return padding;
|
||||
} else {
|
||||
return padding.copyWith(right: padding.left);
|
||||
return padding;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -384,14 +399,17 @@ ParagraphBlockComponentBuilder _buildParagraphBlockComponentBuilder(
|
||||
return ParagraphBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: placeholderText,
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
),
|
||||
showPlaceholder: showParagraphPlaceholder,
|
||||
);
|
||||
@@ -404,14 +422,17 @@ TodoListBlockComponentBuilder _buildTodoListBlockComponentBuilder(
|
||||
return TodoListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_todoList.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
),
|
||||
iconBuilder: (_, node, onCheck) => TodoListIcon(
|
||||
node: node,
|
||||
@@ -432,14 +453,17 @@ BulletedListBlockComponentBuilder _buildBulletedListBlockComponentBuilder(
|
||||
return BulletedListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_bulletList.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
),
|
||||
iconBuilder: (_, node) => BulletedListIcon(node: node),
|
||||
);
|
||||
@@ -452,14 +476,17 @@ NumberedListBlockComponentBuilder _buildNumberedListBlockComponentBuilder(
|
||||
return NumberedListBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_numberList.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
),
|
||||
iconBuilder: (_, node, textDirection) {
|
||||
TextStyle? textStyle;
|
||||
@@ -484,14 +511,17 @@ QuoteBlockComponentBuilder _buildQuoteBlockComponentBuilder(
|
||||
return QuoteBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderText: (_) => LocaleKeys.blockPlaceholders_quote.tr(),
|
||||
textStyle: (node) {
|
||||
if (node.isInHeaderColumn || node.isInHeaderRow) {
|
||||
return configuration.textStyle(node).copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
return configuration.textStyle(node);
|
||||
},
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -504,6 +534,12 @@ HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder(
|
||||
) {
|
||||
return HeadingBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
padding: (node) {
|
||||
if (customHeadingPadding != null) {
|
||||
return customHeadingPadding;
|
||||
@@ -514,9 +550,17 @@ HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder(
|
||||
final factor = pageStyle.fontLayout.factor;
|
||||
final headingPaddings =
|
||||
pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor);
|
||||
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
|
||||
level = level.clamp(1, 6);
|
||||
return EdgeInsets.only(top: headingPaddings.elementAt(level - 1));
|
||||
final level =
|
||||
(node.attributes[HeadingBlockKeys.level] ?? 6).clamp(1, 6);
|
||||
final top = headingPaddings.elementAt(level - 1);
|
||||
EdgeInsets edgeInsets = EdgeInsets.only(top: top);
|
||||
if (node.path.length == 1) {
|
||||
edgeInsets = edgeInsets.copyWith(
|
||||
left: EditorStyleCustomizer.nodeHorizontalPadding,
|
||||
right: EditorStyleCustomizer.nodeHorizontalPadding,
|
||||
);
|
||||
}
|
||||
return edgeInsets;
|
||||
}
|
||||
|
||||
return const EdgeInsets.only(top: 12.0, bottom: 4.0);
|
||||
@@ -528,8 +572,15 @@ HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder(
|
||||
args: [level.toString()],
|
||||
);
|
||||
},
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
),
|
||||
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
|
||||
textStyleBuilder: (level) {
|
||||
return styleCustomizer.headingStyleBuilder(level);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,7 +676,12 @@ DatabaseViewBlockComponentBuilder _buildDatabaseViewBlockComponentBuilder(
|
||||
) {
|
||||
return DatabaseViewBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
|
||||
padding: (node) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return configuration.padding(node);
|
||||
}
|
||||
return const EdgeInsets.symmetric(vertical: 10);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -637,7 +693,23 @@ CalloutBlockComponentBuilder _buildCalloutBlockComponentBuilder(
|
||||
final calloutBGColor = AFThemeExtension.of(context).calloutBGColor;
|
||||
return CalloutBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
padding: (node) => const EdgeInsets.symmetric(vertical: 10),
|
||||
padding: (node) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return configuration.padding(node);
|
||||
}
|
||||
return const EdgeInsets.symmetric(vertical: 10);
|
||||
},
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
textStyle: (node, {TextSpan? textSpan}) => _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
),
|
||||
),
|
||||
inlinePadding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
defaultColor: calloutBGColor,
|
||||
@@ -677,6 +749,7 @@ CodeBlockComponentBuilder _buildCodeBlockComponentBuilder(
|
||||
) {
|
||||
return CodeBlockComponentBuilder(
|
||||
styleBuilder: styleCustomizer.codeBlockStyleBuilder,
|
||||
configuration: configuration,
|
||||
padding: const EdgeInsets.only(left: 20, right: 30, bottom: 34),
|
||||
languagePickerBuilder: codeBlockLanguagePickerBuilder,
|
||||
copyButtonBuilder: codeBlockCopyBuilder,
|
||||
@@ -715,20 +788,32 @@ ToggleListBlockComponentBuilder _buildToggleListBlockComponentBuilder(
|
||||
final factor = pageStyle.fontLayout.factor;
|
||||
final headingPaddings =
|
||||
pageStyle.lineHeightLayout.headingPaddings.map((e) => e * factor);
|
||||
int level = node.attributes[HeadingBlockKeys.level] ?? 6;
|
||||
level = level.clamp(1, 6);
|
||||
return EdgeInsets.only(top: headingPaddings.elementAt(level - 1));
|
||||
final level =
|
||||
(node.attributes[HeadingBlockKeys.level] ?? 6).clamp(1, 6);
|
||||
final top = headingPaddings.elementAt(level - 1);
|
||||
return configuration.padding(node).copyWith(top: top);
|
||||
}
|
||||
|
||||
return const EdgeInsets.only(top: 12.0, bottom: 4.0);
|
||||
},
|
||||
textStyle: (node) {
|
||||
textStyle: (node, {TextSpan? textSpan}) {
|
||||
final textStyle = _buildTextStyleInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
textSpan: textSpan,
|
||||
);
|
||||
final level = node.attributes[ToggleListBlockKeys.level] as int?;
|
||||
if (level == null) {
|
||||
return configuration.textStyle(node);
|
||||
return textStyle;
|
||||
}
|
||||
return styleCustomizer.headingStyleBuilder(level);
|
||||
return textStyle.merge(styleCustomizer.headingStyleBuilder(level));
|
||||
},
|
||||
textAlign: (node) => _buildTextAlignInTableCell(
|
||||
context,
|
||||
node: node,
|
||||
configuration: configuration,
|
||||
),
|
||||
placeholderText: (node) {
|
||||
int? level = node.attributes[ToggleListBlockKeys.level];
|
||||
if (level == null) {
|
||||
@@ -751,9 +836,14 @@ OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder(
|
||||
) {
|
||||
return OutlineBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
placeholderTextStyle: (_) =>
|
||||
placeholderTextStyle: (node, {TextSpan? textSpan}) =>
|
||||
styleCustomizer.outlineBlockPlaceholderStyleBuilder(),
|
||||
padding: (_) => const EdgeInsets.only(top: 12.0, bottom: 4.0),
|
||||
padding: (node) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return configuration.padding(node);
|
||||
}
|
||||
return const EdgeInsets.only(top: 12.0, bottom: 4.0);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -764,7 +854,12 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
|
||||
) {
|
||||
return LinkPreviewBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
padding: (_) => const EdgeInsets.symmetric(vertical: 10),
|
||||
padding: (node) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return configuration.padding(node);
|
||||
}
|
||||
return const EdgeInsets.symmetric(vertical: 10);
|
||||
},
|
||||
),
|
||||
cache: LinkPreviewDataCache(),
|
||||
showMenu: true,
|
||||
@@ -788,12 +883,76 @@ FileBlockComponentBuilder _buildFileBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return FileBlockComponentBuilder(configuration: configuration);
|
||||
return FileBlockComponentBuilder(
|
||||
configuration: configuration,
|
||||
);
|
||||
}
|
||||
|
||||
SubPageBlockComponentBuilder _buildSubPageBlockComponentBuilder(
|
||||
BuildContext context,
|
||||
BlockComponentConfiguration configuration,
|
||||
) {
|
||||
return SubPageBlockComponentBuilder(configuration: configuration);
|
||||
BlockComponentConfiguration configuration, {
|
||||
required EditorStyleCustomizer styleCustomizer,
|
||||
}) {
|
||||
return SubPageBlockComponentBuilder(
|
||||
configuration: configuration.copyWith(
|
||||
textStyle: (node, {TextSpan? textSpan}) =>
|
||||
styleCustomizer.subPageBlockTextStyleBuilder(),
|
||||
padding: (node) {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return const EdgeInsets.symmetric(horizontal: 18);
|
||||
}
|
||||
return configuration.padding(node);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _buildTextStyleInTableCell(
|
||||
BuildContext context, {
|
||||
required Node node,
|
||||
required BlockComponentConfiguration configuration,
|
||||
required TextSpan? textSpan,
|
||||
}) {
|
||||
TextStyle textStyle = configuration.textStyle(node, textSpan: textSpan);
|
||||
|
||||
if (node.isInHeaderColumn ||
|
||||
node.isInHeaderRow ||
|
||||
node.isInBoldColumn ||
|
||||
node.isInBoldRow) {
|
||||
textStyle = textStyle.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
}
|
||||
|
||||
final cellTextColor = node.textColorInColumn ?? node.textColorInRow;
|
||||
|
||||
// enable it if we need to support the text color of the text span
|
||||
// final isTextSpanColorNull = textSpan?.style?.color == null;
|
||||
// final isTextSpanChildrenColorNull =
|
||||
// textSpan?.children?.every((e) => e.style?.color == null) ?? true;
|
||||
|
||||
if (cellTextColor != null) {
|
||||
textStyle = textStyle.copyWith(
|
||||
color: buildEditorCustomizedColor(
|
||||
context,
|
||||
node,
|
||||
cellTextColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return textStyle;
|
||||
}
|
||||
|
||||
TextAlign _buildTextAlignInTableCell(
|
||||
BuildContext context, {
|
||||
required Node node,
|
||||
required BlockComponentConfiguration configuration,
|
||||
}) {
|
||||
final isInTable = node.isInTable;
|
||||
if (!isInTable) {
|
||||
return configuration.textAlign(node);
|
||||
}
|
||||
|
||||
return node.tableAlign.textAlign;
|
||||
}
|
||||
+66
-16
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_file.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/shared/patterns/file_type_patterns.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/draggable_item/draggable_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
const _excludeFromDropTarget = [
|
||||
@@ -16,6 +16,9 @@ const _excludeFromDropTarget = [
|
||||
CustomImageBlockKeys.type,
|
||||
MultiImageBlockKeys.type,
|
||||
FileBlockKeys.type,
|
||||
SimpleTableBlockKeys.type,
|
||||
SimpleTableCellBlockKeys.type,
|
||||
SimpleTableRowBlockKeys.type,
|
||||
];
|
||||
|
||||
class EditorDropHandler extends StatelessWidget {
|
||||
@@ -37,12 +40,39 @@ class EditorDropHandler extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final childWidget = Consumer<EditorDropManagerState>(
|
||||
builder: (context, dropState, _) => DropTarget(
|
||||
enable: dropState.isDropEnabled,
|
||||
onDragExited: (_) => editorState.selectionService.removeDropTarget(),
|
||||
onDragUpdated: _onDragUpdated,
|
||||
onDragDone: _onDragDone,
|
||||
child: child,
|
||||
builder: (context, dropState, _) => DragTarget<ViewPB>(
|
||||
onLeave: (_) {
|
||||
editorState.selectionService.removeDropTarget();
|
||||
disableAutoScrollWhenDragging = false;
|
||||
},
|
||||
onMove: (details) {
|
||||
disableAutoScrollWhenDragging = true;
|
||||
|
||||
if (details.data.id == viewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_onDragUpdated(details.offset);
|
||||
},
|
||||
onWillAcceptWithDetails: (details) {
|
||||
if (!dropState.isDropEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (details.data.id == viewId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onAcceptWithDetails: _onDragViewDone,
|
||||
builder: (context, _, __) => DropTarget(
|
||||
enable: dropState.isDropEnabled,
|
||||
onDragExited: (_) => editorState.selectionService.removeDropTarget(),
|
||||
onDragUpdated: (details) => _onDragUpdated(details.globalPosition),
|
||||
onDragDone: _onDragDone,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -68,9 +98,8 @@ class EditorDropHandler extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _onDragUpdated(DropEventDetails details) {
|
||||
final data = editorState.selectionService
|
||||
.getDropTargetRenderData(details.globalPosition);
|
||||
void _onDragUpdated(Offset position) {
|
||||
final data = editorState.selectionService.getDropTargetRenderData(position);
|
||||
|
||||
if (data != null &&
|
||||
data.dropPath != null &&
|
||||
@@ -79,8 +108,7 @@ class EditorDropHandler extends StatelessWidget {
|
||||
// how we can exclude them from the Drop Target
|
||||
!_excludeFromDropTarget.contains(data.cursorNode?.type)) {
|
||||
// Render the drop target
|
||||
editorState.selectionService
|
||||
.renderDropTargetForOffset(details.globalPosition);
|
||||
editorState.selectionService.renderDropTargetForOffset(position);
|
||||
} else {
|
||||
editorState.selectionService.removeDropTarget();
|
||||
}
|
||||
@@ -113,4 +141,26 @@ class EditorDropHandler extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDragViewDone(DragTargetDetails<ViewPB> details) {
|
||||
editorState.selectionService.removeDropTarget();
|
||||
|
||||
final data =
|
||||
editorState.selectionService.getDropTargetRenderData(details.offset);
|
||||
if (data != null) {
|
||||
final cursorNode = data.cursorNode;
|
||||
final dropPath = data.dropPath;
|
||||
|
||||
if (cursorNode != null && dropPath != null) {
|
||||
if (_excludeFromDropTarget.contains(cursorNode.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final view = details.data;
|
||||
final node = pageMentionNode(view.id);
|
||||
final t = editorState.transaction..insertNode(dropPath, node);
|
||||
editorState.apply(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,15 +97,16 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
customizeFontToolbarItem..isActive = showInAnyTextType,
|
||||
];
|
||||
|
||||
late List<SelectionMenuItem> slashMenuItems;
|
||||
|
||||
List<CharacterShortcutEvent> get characterShortcutEvents {
|
||||
return buildCharacterShortcutEvents(
|
||||
context,
|
||||
documentBloc,
|
||||
styleCustomizer,
|
||||
inlineActionsService,
|
||||
slashMenuItems,
|
||||
(editorState, node) => _customSlashMenuItems(
|
||||
editorState: editorState,
|
||||
node: node,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
final editorKeyboardInterceptor = EditorKeyboardInterceptor();
|
||||
|
||||
Future<bool> showSlashMenu(editorState) async => customSlashCommand(
|
||||
slashMenuItems,
|
||||
_customSlashMenuItems(),
|
||||
shouldInsertSlash: false,
|
||||
style: styleCustomizer.selectionMenuStyleBuilder(),
|
||||
supportSlashMenuNodeTypes: supportSlashMenuNodeTypes,
|
||||
@@ -151,7 +152,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
|
||||
indentableBlockTypes.add(ToggleListBlockKeys.type);
|
||||
convertibleBlockTypes.add(ToggleListBlockKeys.type);
|
||||
slashMenuItems = _customSlashMenuItems();
|
||||
|
||||
effectiveScrollController = widget.scrollController ?? ScrollController();
|
||||
// disable the color parse in the HTML decoder.
|
||||
DocumentHTMLDecoder.enableColorParse = false;
|
||||
@@ -275,12 +276,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
super.reassemble();
|
||||
slashMenuItems = _customSlashMenuItems();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.editorState.selectionNotifier.removeListener(onSelectionChanged);
|
||||
@@ -332,7 +327,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
editorStyle: styleCustomizer.style(),
|
||||
// customize the block builders
|
||||
blockComponentBuilders: buildBlockComponentBuilders(
|
||||
slashMenuItems: slashMenuItems,
|
||||
slashMenuItemsBuilder: (editorState, node) => _customSlashMenuItems(
|
||||
editorState: editorState,
|
||||
node: node,
|
||||
),
|
||||
context: context,
|
||||
editorState: widget.editorState,
|
||||
styleCustomizer: widget.styleCustomizer,
|
||||
@@ -408,42 +406,18 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
);
|
||||
}
|
||||
|
||||
List<SelectionMenuItem> _customSlashMenuItems() {
|
||||
final isLocalMode = context.read<DocumentBloc>().isLocalMode;
|
||||
return [
|
||||
if (!isLocalMode) aiWriterSlashMenuItem,
|
||||
textSlashMenuItem,
|
||||
heading1SlashMenuItem,
|
||||
heading2SlashMenuItem,
|
||||
heading3SlashMenuItem,
|
||||
imageSlashMenuItem,
|
||||
bulletedListSlashMenuItem,
|
||||
numberedListSlashMenuItem,
|
||||
todoListSlashMenuItem,
|
||||
dividerSlashMenuItem,
|
||||
quoteSlashMenuItem,
|
||||
tableSlashMenuItem,
|
||||
referencedDocSlashMenuItem,
|
||||
gridSlashMenuItem(documentBloc),
|
||||
referencedGridSlashMenuItem,
|
||||
kanbanSlashMenuItem(documentBloc),
|
||||
referencedKanbanSlashMenuItem,
|
||||
calendarSlashMenuItem(documentBloc),
|
||||
referencedCalendarSlashMenuItem,
|
||||
calloutSlashMenuItem,
|
||||
outlineSlashMenuItem,
|
||||
mathEquationSlashMenuItem,
|
||||
codeBlockSlashMenuItem,
|
||||
toggleListSlashMenuItem,
|
||||
toggleHeading1SlashMenuItem,
|
||||
toggleHeading2SlashMenuItem,
|
||||
toggleHeading3SlashMenuItem,
|
||||
emojiSlashMenuItem,
|
||||
dateOrReminderSlashMenuItem,
|
||||
photoGallerySlashMenuItem,
|
||||
fileSlashMenuItem,
|
||||
subPageSlashMenuItem,
|
||||
];
|
||||
List<SelectionMenuItem> _customSlashMenuItems({
|
||||
EditorState? editorState,
|
||||
Node? node,
|
||||
}) {
|
||||
final documentBloc = context.read<DocumentBloc>();
|
||||
final isLocalMode = documentBloc.isLocalMode;
|
||||
return slashMenuItemsBuilder(
|
||||
editorState: editorState,
|
||||
node: node,
|
||||
isLocalMode: isLocalMode,
|
||||
documentBloc: documentBloc,
|
||||
);
|
||||
}
|
||||
|
||||
(bool, Selection?) _computeAutoFocusParameters() {
|
||||
@@ -531,6 +505,10 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
Position(path: lastNode.path),
|
||||
);
|
||||
}
|
||||
|
||||
transaction.customSelectionType = SelectionType.inline;
|
||||
transaction.reason = SelectionUpdateReason.uiEvent;
|
||||
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
|
||||
@@ -574,7 +552,11 @@ Color? buildEditorCustomizedColor(
|
||||
return AFThemeExtension.of(context).tableCellBGColor;
|
||||
}
|
||||
|
||||
return null;
|
||||
try {
|
||||
return colorString.tryToColor();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool showInAnyTextType(EditorState editorState) {
|
||||
|
||||
+7
-7
@@ -107,7 +107,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
}
|
||||
|
||||
Node _copyBlock(Node node) {
|
||||
Node copiedNode = node.copyWith();
|
||||
Node copiedNode = node.deepCopy();
|
||||
|
||||
final type = node.type;
|
||||
final builder = blockComponentBuilder[type];
|
||||
@@ -158,7 +158,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
)
|
||||
.firstOrNull;
|
||||
if (cell != null) {
|
||||
newChildren.add(cell.copyWith());
|
||||
newChildren.add(cell.deepCopy());
|
||||
} else {
|
||||
newChildren.add(
|
||||
tableCellNode('', i, j),
|
||||
@@ -203,7 +203,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
.firstOrNull;
|
||||
row.add(
|
||||
simpleTableCellBlockNode(
|
||||
children: [cell?.children.first.copyWith() ?? paragraphNode()],
|
||||
children: [cell?.children.first.deepCopy() ?? paragraphNode()],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -327,10 +327,10 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
afterNode = afterNode.copyWith(children: []);
|
||||
afterNode = await _handleSubPageNode(afterNode, node);
|
||||
insertedNode.add(afterNode);
|
||||
insertedNode.addAll(node.children.map((e) => e.copyWith()));
|
||||
insertedNode.addAll(node.children.map((e) => e.deepCopy()));
|
||||
} else if (!EditorOptionActionType.turnInto.supportTypes
|
||||
.contains(node.type)) {
|
||||
afterNode = node.copyWith();
|
||||
afterNode = node.deepCopy();
|
||||
insertedNode.add(afterNode);
|
||||
} else {
|
||||
afterNode = await _handleSubPageNode(afterNode, node);
|
||||
@@ -463,7 +463,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
},
|
||||
children: [
|
||||
...node.children,
|
||||
...insertedNodes.map((e) => e.copyWith()),
|
||||
...insertedNodes.map((e) => e.deepCopy()),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -509,7 +509,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
|
||||
|
||||
Log.info('Turn into page');
|
||||
|
||||
final insertedNodes = selectedNodes.map((n) => n.copyWith()).toList();
|
||||
final insertedNodes = selectedNodes.map((n) => n.deepCopy()).toList();
|
||||
final document = Document.blank()..insert([0], insertedNodes);
|
||||
final name = await _extractNameFromNodes(selectedNodes);
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
|
||||
super.initState();
|
||||
|
||||
// copy the node to avoid the node in document being updated
|
||||
node = widget.blockComponentContext.node.copyWith();
|
||||
node = widget.blockComponentContext.node.deepCopy();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ class _DraggleOptionButtonFeedbackState
|
||||
}
|
||||
|
||||
void _setupLockComponentContext() {
|
||||
node = widget.blockComponentContext.node.copyWith();
|
||||
node = widget.blockComponentContext.node.deepCopy();
|
||||
blockComponentContext = BlockComponentContext(
|
||||
widget.blockComponentContext.buildContext,
|
||||
node,
|
||||
|
||||
+4
-2
@@ -65,7 +65,7 @@ Future<void> dragToMoveNode(
|
||||
Log.info('Moving node($node, ${node.path}) to path($newPath)');
|
||||
|
||||
final transaction = editorState.transaction;
|
||||
transaction.insertNode(newPath, node.copyWith());
|
||||
transaction.insertNode(newPath, node.deepCopy());
|
||||
transaction.deleteNode(node);
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
@@ -140,7 +140,9 @@ bool shouldIgnoreDragTarget({
|
||||
}
|
||||
|
||||
final targetNode = editorState.getNodeAtPath(targetPath);
|
||||
if (targetNode != null && targetNode.isInTable) {
|
||||
if (targetNode != null &&
|
||||
targetNode.isInTable &&
|
||||
targetNode.type != SimpleTableBlockKeys.type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ class VisualDragArea extends StatelessWidget {
|
||||
final DragAreaBuilderData data;
|
||||
final Node dragNode;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final targetNode = data.targetNode;
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ class MobileBlockActionButtons extends StatelessWidget {
|
||||
case BlockActionBottomSheetType.duplicate:
|
||||
transaction.insertNode(
|
||||
node.path.next,
|
||||
node.copyWith(),
|
||||
node.deepCopy(),
|
||||
);
|
||||
break;
|
||||
case BlockActionBottomSheetType.insertAbove:
|
||||
|
||||
+28
-13
@@ -1,10 +1,10 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
enum OptionAlignType {
|
||||
left,
|
||||
@@ -27,11 +27,11 @@ enum OptionAlignType {
|
||||
FlowySvgData get svg {
|
||||
switch (this) {
|
||||
case OptionAlignType.left:
|
||||
return FlowySvgs.align_left_s;
|
||||
return FlowySvgs.table_align_left_s;
|
||||
case OptionAlignType.center:
|
||||
return FlowySvgs.align_center_s;
|
||||
return FlowySvgs.table_align_center_s;
|
||||
case OptionAlignType.right:
|
||||
return FlowySvgs.align_right_s;
|
||||
return FlowySvgs.table_align_right_s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ class AlignOptionAction extends PopoverActionCell {
|
||||
Widget? leftIcon(Color iconColor) {
|
||||
return FlowySvg(
|
||||
align.svg,
|
||||
size: const Size.square(12),
|
||||
).padding(all: 2.0);
|
||||
size: const Size.square(18),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,7 +102,11 @@ class AlignOptionAction extends PopoverActionCell {
|
||||
return HoverButton(
|
||||
onTap: () => onTap(e.inner),
|
||||
itemHeight: ActionListSizes.itemHeight,
|
||||
leftIcon: leftIcon,
|
||||
leftIcon: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: leftIcon,
|
||||
),
|
||||
name: e.name,
|
||||
rightIcon: rightIcon,
|
||||
);
|
||||
@@ -115,7 +119,9 @@ class AlignOptionAction extends PopoverActionCell {
|
||||
return OptionAlignType.center;
|
||||
}
|
||||
final node = editorState.getNodeAtPath(selection.start.path);
|
||||
final align = node?.attributes[blockComponentAlign];
|
||||
final align = node?.type == SimpleTableBlockKeys.type
|
||||
? node?.tableAlign.key
|
||||
: node?.attributes[blockComponentAlign];
|
||||
return OptionAlignType.fromString(align);
|
||||
}
|
||||
|
||||
@@ -131,11 +137,20 @@ class AlignOptionAction extends PopoverActionCell {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
blockComponentAlign: align.name,
|
||||
});
|
||||
await editorState.apply(transaction);
|
||||
// the align attribute for simple table is not same as the align type,
|
||||
// so we need to convert the align type to the align attribute
|
||||
if (node.type == SimpleTableBlockKeys.type) {
|
||||
await editorState.updateTableAlign(
|
||||
tableNode: node,
|
||||
align: TableAlign.fromString(align.name),
|
||||
);
|
||||
} else {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
blockComponentAlign: align.name,
|
||||
});
|
||||
await editorState.apply(transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ enum EditorOptionActionType {
|
||||
case EditorOptionActionType.align:
|
||||
return {
|
||||
ImageBlockKeys.type,
|
||||
SimpleTableBlockKeys.type,
|
||||
};
|
||||
case EditorOptionActionType.depth:
|
||||
return {
|
||||
|
||||
+34
-19
@@ -1,6 +1,6 @@
|
||||
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -23,10 +23,13 @@ class EmojiPickerButton extends StatelessWidget {
|
||||
this.buttonSize,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final EmojiIconData emoji;
|
||||
final double emojiSize;
|
||||
final Size emojiPickerSize;
|
||||
final void Function(String emoji, PopoverController? controller) onSubmitted;
|
||||
final void Function(
|
||||
SelectedEmojiIconResult result,
|
||||
PopoverController? controller,
|
||||
) onSubmitted;
|
||||
final PopoverController popoverController = PopoverController();
|
||||
final Widget? defaultIcon;
|
||||
final Offset? offset;
|
||||
@@ -81,10 +84,13 @@ class _DesktopEmojiPickerButton extends StatelessWidget {
|
||||
this.buttonSize,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final EmojiIconData emoji;
|
||||
final double emojiSize;
|
||||
final Size emojiPickerSize;
|
||||
final void Function(String emoji, PopoverController? controller) onSubmitted;
|
||||
final void Function(
|
||||
SelectedEmojiIconResult result,
|
||||
PopoverController? controller,
|
||||
) onSubmitted;
|
||||
final PopoverController popoverController = PopoverController();
|
||||
final Widget? defaultIcon;
|
||||
final Offset? offset;
|
||||
@@ -93,8 +99,10 @@ class _DesktopEmojiPickerButton extends StatelessWidget {
|
||||
final bool showBorder;
|
||||
final bool enable;
|
||||
final Size? buttonSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showDefault = emoji.isEmpty && defaultIcon != null;
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
constraints: BoxConstraints.expand(
|
||||
@@ -108,9 +116,11 @@ class _DesktopEmojiPickerButton extends StatelessWidget {
|
||||
width: emojiPickerSize.width,
|
||||
height: emojiPickerSize.height,
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: EmojiSelectionMenu(
|
||||
onSubmitted: (emoji) => onSubmitted(emoji, popoverController),
|
||||
onExit: () {},
|
||||
child: FlowyIconEmojiPicker(
|
||||
initialType: emoji.type.toPickerTabType(),
|
||||
onSelectedEmoji: (r) {
|
||||
onSubmitted(r, popoverController);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
@@ -129,9 +139,9 @@ class _DesktopEmojiPickerButton extends StatelessWidget {
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(left: 2.0),
|
||||
expandText: false,
|
||||
text: emoji.isEmpty && defaultIcon != null
|
||||
text: showDefault
|
||||
? defaultIcon!
|
||||
: FlowyText.emoji(emoji, fontSize: emojiSize),
|
||||
: RawEmojiIconWidget(emoji: emoji, emojiSize: emojiSize),
|
||||
onTap: enable ? popoverController.show : null,
|
||||
),
|
||||
),
|
||||
@@ -149,9 +159,12 @@ class _MobileEmojiPickerButton extends StatelessWidget {
|
||||
this.margin,
|
||||
});
|
||||
|
||||
final String emoji;
|
||||
final EmojiIconData emoji;
|
||||
final double emojiSize;
|
||||
final void Function(String emoji, PopoverController? controller) onSubmitted;
|
||||
final void Function(
|
||||
SelectedEmojiIconResult result,
|
||||
PopoverController? controller,
|
||||
) onSubmitted;
|
||||
final String? title;
|
||||
final bool enable;
|
||||
final EdgeInsets? margin;
|
||||
@@ -162,21 +175,23 @@ class _MobileEmojiPickerButton extends StatelessWidget {
|
||||
useIntrinsicWidth: true,
|
||||
margin:
|
||||
margin ?? const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
|
||||
text: FlowyText.emoji(
|
||||
emoji,
|
||||
fontSize: emojiSize,
|
||||
optimizeEmojiAlign: true,
|
||||
text: RawEmojiIconWidget(
|
||||
emoji: emoji,
|
||||
emojiSize: emojiSize,
|
||||
),
|
||||
onTap: enable
|
||||
? () async {
|
||||
final result = await context.push<EmojiPickerResult>(
|
||||
final result = await context.push<EmojiIconData>(
|
||||
Uri(
|
||||
path: MobileEmojiPickerScreen.routeName,
|
||||
queryParameters: {MobileEmojiPickerScreen.pageTitle: title},
|
||||
queryParameters: {
|
||||
MobileEmojiPickerScreen.pageTitle: title,
|
||||
MobileEmojiPickerScreen.iconSelectedType: emoji.type.name,
|
||||
},
|
||||
).toString(),
|
||||
);
|
||||
if (result != null) {
|
||||
onSubmitted(result.emoji, null);
|
||||
onSubmitted(result.toSelectedResult(), null);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ class MarkdownTextRobot {
|
||||
|
||||
// Convert markdown and deep copy nodes
|
||||
final nodes = customMarkdownToDocument(_markdownText).root.children.map(
|
||||
(node) => node.copyWith(),
|
||||
(node) => node.deepCopy(),
|
||||
); // deep copy the nodes to avoid the linked entities being changed.
|
||||
|
||||
// Insert new nodes at selection start
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ class SelectableItemListMenu extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScrollablePositionedList.builder(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
shrinkWrap: shrinkWrap,
|
||||
itemCount: items.length,
|
||||
itemScrollController: controller,
|
||||
|
||||
+6
-4
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart' show LocaleKeys;
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart'
|
||||
show StringTranslateExtension;
|
||||
@@ -195,12 +196,12 @@ class _CalloutBlockComponentWidgetState
|
||||
key: ValueKey(widget.node.id + emoji),
|
||||
enable: editorState.editable,
|
||||
title: '',
|
||||
emoji: emoji,
|
||||
emoji: EmojiIconData.emoji(emoji),
|
||||
emojiSize: emojiSize,
|
||||
showBorder: false,
|
||||
buttonSize: emojiButtonSize,
|
||||
onSubmitted: (emoji, controller) {
|
||||
setEmoji(emoji);
|
||||
setEmoji(emoji.emoji);
|
||||
controller?.close();
|
||||
},
|
||||
),
|
||||
@@ -255,11 +256,12 @@ class _CalloutBlockComponentWidgetState
|
||||
node: widget.node,
|
||||
editorState: editorState,
|
||||
placeholderText: placeholderText,
|
||||
textAlign: alignment?.toTextAlign ?? textAlign,
|
||||
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
|
||||
textStyle,
|
||||
textStyleWithTextSpan(textSpan: textSpan),
|
||||
),
|
||||
placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle(
|
||||
placeholderTextStyle,
|
||||
placeholderTextStyleWithTextSpan(textSpan: textSpan),
|
||||
),
|
||||
textDirection: textDirection,
|
||||
cursorColor: editorState.editorStyle.cursorColor,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class _CopyButton extends StatelessWidget {
|
||||
}
|
||||
|
||||
final document = Document.blank()
|
||||
..insert([0], [node.copyWith()])
|
||||
..insert([0], [node.deepCopy()])
|
||||
..toJson();
|
||||
|
||||
await getIt<ClipboardService>().setData(
|
||||
|
||||
+4
@@ -222,6 +222,9 @@ class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> {
|
||||
void onArrowKey(AxisDirection direction) {
|
||||
if (filteredLanguages.isEmpty) return;
|
||||
final isUp = direction == AxisDirection.up;
|
||||
if (selectedIndex < 0) {
|
||||
selectedIndex = isUp ? 0 : -1;
|
||||
}
|
||||
final length = filteredLanguages.length;
|
||||
setState(() {
|
||||
if (isUp) {
|
||||
@@ -232,6 +235,7 @@ class _LanguageSelectionPopoverState extends State<_LanguageSelectionPopover> {
|
||||
});
|
||||
languageListController.scrollTo(
|
||||
index: selectedIndex,
|
||||
alignment: 0.5,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
+5
@@ -13,6 +13,11 @@ final List<List<ContextMenuItem>> customContextMenuItems = [
|
||||
getName: LocaleKeys.document_plugins_contextMenu_paste.tr,
|
||||
onPressed: (editorState) => customPasteCommand.execute(editorState),
|
||||
),
|
||||
ContextMenuItem(
|
||||
getName: LocaleKeys.document_plugins_contextMenu_pasteAsPlainText.tr,
|
||||
onPressed: (editorState) =>
|
||||
customPastePlainTextCommand.execute(editorState),
|
||||
),
|
||||
ContextMenuItem(
|
||||
getName: LocaleKeys.document_plugins_contextMenu_cut.tr,
|
||||
onPressed: (editorState) => customCutCommand.execute(editorState),
|
||||
|
||||
Loaded 100 of 764 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user