Compare commits

...
34 Commits
Author SHA1 Message Date
Richard Shiue 8246af93c7 chore: fetch rag only on open chat 2024-12-27 10:10:24 +08:00
Richard Shiue 36d8a32670 chore: implement ui 2024-12-26 14:41:03 +08:00
Richard Shiue d939d7a561 chore: merge remote-tracking branch 'upstream/main' into chat_rag_only 2024-12-26 14:27:29 +08:00
Lucas 7db11c7cfd chore: fix release builder issue (#7058)
* chore: use --locked to install cargo-make

* chore: upgrade to macos 13
2024-12-26 14:03:23 +08:00
Lucas 33888d583b chore: bump version 0.7.9 (#7056) 2024-12-26 13:49:55 +08:00
3959cdba3a feat: add option to paste plain text (#7045)
* feat: add option to paste plain text

* refactor: optimize the code

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: Lucas <lucas.xu@appflowy.io>

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: Lucas <lucas.xu@appflowy.io>
2024-12-26 13:22:27 +08:00
nathan 960f9b9a93 chore: support set rag only 2024-12-26 13:15:20 +08:00
Richard ShiueandLucas 200b367e4c feat: add ai message content to document (#7041)
* feat: add ai response content to page

* chore: apply suggestions from code review

Co-authored-by: Lucas <lucas.xu@appflowy.io>

* chore: apply suggestions from code review

* chore: reorganize code

* chore: i18n

* chore: enable opening the document in the sidebar

* fix: async await

* chore: rename ai message action bar widget

* feat: make transactions be reflected in the opened document

* chore: don't forget to close the bloc

* fix: isLastLineEmpty

* chore: code cleanup

* fix: sync after EditorState.apply

* chore: decrease visibility of DocumentBlocMap

* chore: add back missing assert

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2024-12-26 12:18:48 +08:00
Richard Shiue 956d2dfd07 fix(flutter_desktop): row detail checklist cell "create new" text field doesn't clear after clicking on create button (#7055)
* fix: clear text controller when clicking on add button no matter what

* test: add integration test
2024-12-26 12:18:17 +08:00
Lucas 83e50d376e feat: support plus menu in table cell on mobile (#7048)
* feat: support plus menu in table cell on mobile

* test: support plus menu in table cell on mobile

* feat: add lightImpact feedback

* chore: optimize the action sheet
2024-12-26 11:09:56 +08:00
Mathias Mogensen 802a667907 feat: drag view into document to link to view (#6991)
* feat: drag view into document

* chore: remove redundant method call
2024-12-26 09:57:01 +08:00
e67e9cc647 feat: support recent section in icon picker (#7015)
* fix: scroll event will be intercepted by tooltips

* fix: improve the logic to filter emojis or icons by keyword

* feat: add the recent icons and emojis to the selector

* refactor: optimize the code

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: LucasXu0 <lucas.xu@appflowy.io>

* fix: ensure the focus of emoji_search_bar not be lost within a second

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: LucasXu0 <lucas.xu@appflowy.io>
2024-12-26 09:55:29 +08:00
Lucas 07c4389f6a feat: refactor slash menu items to support showing in table cell (#7038)
* feat: refactor slash menu items to support showing in table cell

* chore: remove default slash menu items

* feat: support customizing slash commands in table cell

* fix: unable to delete math equation

* fix: underline issue

* feat: support text align in table

* fix: image alignment doesn't work in simple table cell

* fix: reduce the padding in table cell

* fix: drag column / row error in table

* test: support slash menu in table
2024-12-25 09:28:26 +08:00
Nathan.foooandMathias Mogensen 171c5634f2 fix: calculation UI refresh (#6615)
* chore: add test

* chore: update test

* chore: create events

* chore: load csv

* test: add events

* test: clean

* chore: fix test

* chore: fix test

* chore: fix test

* chore: fmt

---------

Co-authored-by: Mathias Mogensen <mathias@appflowy.io>
2024-12-24 21:27:34 +08:00
Lucas d7bda10e6f fix: do not wrap the inline math equation in a new line (#7035)
* fix: do not wrap the inline math equation in new line

* test: inline equation text and inline text align
2024-12-24 09:39:24 +08:00
Lucas 20bff9003e feat: support table align on desktop (#7034)
* feat: support table align on desktop

* test: update table align

* test: add integration test
2024-12-24 09:39:15 +08:00
Lucas 6e0534400b feat: reordering table columns and rows on mobile (#7036)
* feat: support table overflow

* fix: heading padding

* feat: support reordering table

* feat: highlight cell border when dragging

* chore: optimize the highlight border render logic

* fix: table menu error

* test: insert a table / insert column / insert row test

* test: delete column / delete row

* test: delete column / delete row

* test: enable header row / header column

* test: clear content, bold text
2024-12-23 21:07:09 +08:00
Lucas 17c116a53b feat: support table block on mobile (#7001)
* feat: support inserting table from + menu

* feat: support row/column action sheet

* feat: highlight reorder button

* feat: support table action mobile

* feat: support insert row / insert column

* feat: add more actions in the table bottom sheet

* feat: implement the action logic for table action menu

* feat: support clear content

* feat: enable header column / header row

* fix: unable to cancel highlight status after closing table action menu

* fix: table cell parse error

* feat: support insert left/insert right

* feat: add delete in table action menu

* feat: support copy a table row/column

* feat: support copy paste in table cell

* feat: support cut in table

* feat: disable cut if no content in clipboard

* chore: refactor table action bottom sheet

* feat: use ISimpleTableBottomSheetActions to manage the mobile action bottom sheet

* feat: add bold/text color/text background color in table bottom sheet

* feat: add align in table bottom sheet

* feat: support bold/text color/text background color update command

* feat: add align action to table action menu

* feat: integrate bold command in table bottom sheet

* feat: integrate text color and cell background color command in table bottom sheet

* feat: integrate text color command and cell background color command in table action menu

* chore: remove unused code

* fix: page style json parse error

* feat: support copy link to block / duplicate in table action menu

* feat: support align in table action menu

* feat: add quick actions in table action menu

* chore: update translations

* feat: integrate copy paste in table action menu

* feat: integrate align in table action menu

* fix: flutter ci

* test: add copy/paste/cut test

* fix: align the table with the document title

* fix: flutter tests

* fix: flutter ci
2024-12-23 10:29:41 +08:00
Richard Shiue 27b769362b chore: remove unused response state (#7031) 2024-12-23 10:03:20 +08:00
Richard Shiue b8e7d57ee6 fix: regenerating the last answer doesn't work (#7029)
* fix: regenerate not working for last message

* chore: code cleanup
2024-12-22 14:47:11 +08:00
Richard Shiue ed052c6792 chore: include parent view id in initial chat settings (#7030)
* chore: include parent view id in initial chat settings

* chjore: add a tooltip
2024-12-22 14:46:54 +08:00
Richard Shiue 5cd2c63320 fix: update rag_ids not saving to local store (#7028)
* fix: update rag_ids not saving to local store

* chore: bump up send button size
2024-12-21 14:31:48 +08:00
FakhriAzzouz 15567a81e9 chore(i18n): update ar-SA translations (#7017)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2024-12-20 23:23:59 +08:00
Richard Shiue 7ab68dcc2c chore(flutter_desktop): ai chat ui improvements (#7025)
* chore: improve hover action bar inner radius

* chore: improve ai input appearance
2024-12-20 23:22:48 +08:00
Richard Shiue 30131fd9e4 chore: adjust select sources (#7019)
* chore: adjust select sources

* chore: restrict number of selected highest-level documents

* chore: ignore chat views

* chore: code cleanup
2024-12-20 22:43:26 +08:00
Richard Shiue ddcdd545d9 chore: regenerate response improvements (#7018)
* fix: disable sending message while streaming

* chore: don't allow regenerate while streaming
2024-12-20 11:42:43 +08:00
Nathan.fooo d25a399aba chore: sync chat document when open chat (#7016)
* chore: sync document

* chore: auto create chat title
2024-12-20 00:15:01 +08:00
ZackandLucas.Xu ee96a44fef feat: use new api to get the workspace member role (#6783)
* chore: update to latest client api

* chore: merge with main

* chore: remove unneeded code

* fix: sqlite migration

* fix: cargo fmt

* feat: use new api to get the member role

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-12-19 15:19:24 +08:00
Morn dda3962249 feat: combine icons and emojis into one menu(#6806) (#6998) 2024-12-19 15:19:10 +08:00
Morn c2743472bc fix: the index error when deleting text and then using the up or down arrow keys (#7009) 2024-12-19 14:40:11 +08:00
Richard Shiueandnathan e73fd56152 feat: regenerate ai response (#7006)
* feat: regenerate ai response

* chore: find question id instead of assuming

* chore: fix clippy

* chore: show local messages if they were there

* chore: remove duplicate code

* chore: fix loading message

* chore: revert unintended translation key removal

* chore: update translation for ai service unavailable

* chore: fix initial chat message load

---------

Co-authored-by: nathan <nathan@appflowy.io>
2024-12-19 14:13:53 +08:00
Richard Shiue 04a013f7ee chore: fix clippy (#7007) 2024-12-18 15:34:23 +08:00
Richard Shiue b966e3ea07 feat: select sources for ai prompt (#6975)
* feat: select sources for ai prompt

* feat: mobile

* chore: disable mention page

* chore: disable select sources when on local ai

* chore: code cleanup
2024-12-18 14:34:11 +08:00
Nathan.fooo 9d53f758d4 chore: bump client api 088 (#7004)
* chore: bump client api

* chore: bump client api

* chore: fix clippy
2024-12-18 12:12:58 +08:00
300 changed files with 14260 additions and 4190 deletions

No files matched your search

+5 -5
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+21 -9
View File
@@ -1,17 +1,29 @@
# Release Notes
## Version 0.7.7 - 09/12/2024
## Version 0.7.9 - 25/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
### Bug Fixes
## 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
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.7.8"
APPFLOWY_VERSION = "0.7.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -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();
@@ -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();
@@ -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);
},
);
}
@@ -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),
);
});
});
@@ -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();
@@ -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),
);
});
});
}
@@ -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 {
@@ -82,7 +82,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),
@@ -1,83 +1,229 @@
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/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.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,
);
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,
),
);
}
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
testWidgets('Update page emoji in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
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 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 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,
);
}
});
}
@@ -1,5 +1,6 @@
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/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -38,7 +39,11 @@ void main() {
await tester.tapEmoji(emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
EmojiIconData.emoji(emoji),
);
});
});
}
@@ -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();
}
@@ -0,0 +1,440 @@
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_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.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);
// 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);
// clear content
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents
.tr(),
),
);
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_toggleHeading1ShortForm.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));
}
});
});
}
@@ -8,8 +8,11 @@ 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/text_field/text_filed_with_metric_lines.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
@@ -608,7 +611,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 +623,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 +635,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 +647,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 +849,55 @@ 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);
}
}
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,46 @@ 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);
expect(find.byType(FlowyIconPicker), findsNothing);
await tap(iconTab);
await pumpAndSettle();
expect(find.byType(FlowyIconPicker), findsOneWidget);
final selectedSvg = find.descendant(
of: find.byType(FlowyIconPicker),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
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
@@ -199,6 +204,7 @@ SPEC CHECKSUMS:
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -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';
}
@@ -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) ...[
EmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
),
const HSpace(4),
],
@@ -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,
),
),
),
@@ -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(),
];
}
}
@@ -211,14 +211,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 +237,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(
@@ -228,7 +228,7 @@ 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: {
@@ -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(
@@ -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,21 +173,19 @@ 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,
),
WidgetSpan(
child: SizedBox(
width: 20,
child: EmojiIconWidget(
emoji: icon,
emojiSize: 17.0,
),
),
),
if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
TextSpan(
@@ -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,
@@ -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: [
@@ -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,46 @@ 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.ragIds,
onlyUseSelectedSources: settings.ragOnly,
),
);
},
updateSelectedSources: (selectedSourcesIds) async {
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
final ragIds = RepeatedRagId(ragIds: selectedSourcesIds);
final payload = UpdateChatSettingsPB.create()
..chatId = ChatId(value: chatId)
..ragIds = ragIds;
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
setRagOnly: (ragOnly) async {
emit(state.copyWith(onlyUseSelectedSources: ragOnly));
final payload = UpdateChatSettingsPB.create()
..chatId = ChatId(value: chatId)
..ragOnly = ragOnly;
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
);
},
);
@@ -332,12 +356,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();
@@ -402,8 +439,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
question.messageId,
);
add(ChatEvent.finishSending(question));
add(ChatEvent.startAnswerStreaming(streamAnswer));
lastSentMessage = question;
add(const ChatEvent.finishSending());
add(ChatEvent.receiveMessage(streamAnswer));
}
},
(err) {
@@ -430,6 +468,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
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(
answerStream!,
answerMessageId - 1,
).copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
);
}
Message _createAnswerStreamMessage(
AnswerStream stream,
Int64 questionMessageId,
@@ -488,22 +562,48 @@ 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;
const factory ChatEvent.setRagOnly({
required bool ragOnly,
}) = _SetRagOnly;
// 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 +628,16 @@ class ChatEvent with _$ChatEvent {
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required ChatLoadingState loadingState,
required bool onlyUseSelectedSources,
required List<String> selectedSourceIds,
required LoadChatMessageStatus loadingState,
required PromptResponseState promptResponseState,
}) = _ChatState;
factory ChatState.initial() => const ChatState(
loadingState: ChatLoadingState.loading(),
onlyUseSelectedSources: false,
selectedSourceIds: [],
loadingState: LoadChatMessageStatus.loading,
promptResponseState: PromptResponseState.ready,
);
}
@@ -0,0 +1,96 @@
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,
];
final insertPath = rootIsEmpty || listEquals(lastNodeOrNull.path, const [0])
? 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,
}
@@ -0,0 +1,399 @@
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: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 = [];
ChatSource? source;
List<ChatSource> selectedSources = [];
String filter = '';
void updateOnlyUseSelectedSources(bool onlyUseSelectedSources) {
if (state.onlyUseSelectedSources != onlyUseSelectedSources) {
emit(state.copyWith(onlyUseSelectedSources: onlyUseSelectedSources));
}
}
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds = [...newSelectedSourceIds];
}
void refreshSources(ViewPB view) async {
filter = "";
final newSource = await _recursiveBuild(view, null);
_restrictSelectionIfNecessary(newSource.children);
final selected = _buildSelectedSources(newSource).toList();
newSource.toggleIsExpanded();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: [newSource],
),
);
source?.dispose();
source = newSource.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 (source == null) {
emit(ChatSettingsState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
final visible = [_buildSearchResults(source!)].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 (source == null) {
return;
}
_recursiveUpdateSelectedStatus(source!);
_restrictSelectionIfNecessary(source!.children);
for (final visibleSource in state.visibleSources) {
visibleSource.dispose();
}
final visible = [_buildSearchResults(source!)].nonNulls.toList();
selectedSources
..forEach((e) => e.dispose())
..clear()
..addAll(_buildSelectedSources(source!));
emit(
state.copyWith(
visibleSources: visible,
selectedSources:
selectedSources.map(_buildSearchResults).nonNulls.toList(),
),
);
}
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 {
source?.findChildBySourceId(chatSource.view.id)?.toggleIsExpanded();
}
}
@override
Future<void> close() {
source?.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 bool onlyUseSelectedSources,
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
factory ChatSettingsState.initial() => const ChatSettingsState(
onlyUseSelectedSources: false,
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';
@@ -19,6 +9,7 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
@@ -28,14 +19,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 +117,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 +138,9 @@ class _ChatContentPage extends StatelessWidget {
),
_buildInput(context),
],
);
},
);
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
),
),
@@ -204,14 +194,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 +209,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 +280,61 @@ 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: (ragOnly, ids) {
if (ragOnly != chatBloc.state.onlyUseSelectedSources) {
chatBloc.add(
ChatEvent.setRagOnly(
ragOnly: ragOnly,
),
);
}
if (!listEquals(ids, chatBloc.state.selectedSourceIds)) {
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 +361,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);
}
}
}
@@ -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,
),
);
}
@@ -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(bool, 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,30 @@ class _PromptBottomActions extends StatelessWidget {
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final void Function(bool, 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) {
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 +534,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 +588,7 @@ class _PromptBottomActions extends StatelessWidget {
Widget _sendButton() {
return PromptInputSendButton(
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
buttonSize: DesktopAIPromptSizes.actionBarHeight,
iconSize: DesktopAIPromptSizes.sendButtonSize,
state: sendButtonState,
onSendPressed: onSendPressed,
@@ -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).cardColor,
child: PromptInputMobileSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
),
);
}
}
@@ -0,0 +1,263 @@
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, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
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 {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources(spaceView);
}
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;
}
}
@@ -0,0 +1,551 @@
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(bool, 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((_) {
final chatBlocState = context.read<ChatBloc>().state;
cubit
..updateSelectedSources(chatBlocState.selectedSourceIds)
..updateOnlyUseSelectedSources(chatBlocState.onlyUseSelectedSources);
});
}
@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, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateOnlyUseSelectedSources(state.onlyUseSelectedSources)
..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: () {
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
},
onClose: () {
widget.onUpdateSelectedSources(
cubit.state.onlyUseSelectedSources,
cubit.selectedSourceIds,
);
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
},
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),
),
),
Container(
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
height: 30,
child: FlowyButton(
text: FlowyText(
LocaleKeys.chat_onlyUseRags.tr(),
overflow: TextOverflow.ellipsis,
),
onTap: () {
context
.read<ChatSettingsCubit>()
.updateOnlyUseSelectedSources(
!state.onlyUseSelectedSources,
);
},
rightIcon: state.onlyUseSelectedSources
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).colorScheme.primary,
)
: null,
),
),
_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(
style: HoverStyle(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => 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));
}
@@ -0,0 +1,451 @@
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_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(documentId);
DocumentBloc.findOpen(documentId)?.forceReloadDocumentState();
} 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();
await onAddToExistingPage(documentId);
final view =
await ViewBackendService.getView(documentId).toNullable();
if (context.mounted) {
openPageFromMessage(context, view);
}
},
),
);
}
Future<void> onAddToExistingPage(String documentId) async {
await ChatEditDocumentService.addMessageToPage(
documentId,
widget.textMessage,
);
await Future.delayed(const Duration(milliseconds: 500));
}
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) {
openPageFromMessage(context, newView);
}
}
}
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,
),
),
),
);
}
}
@@ -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(),
);
}
}
@@ -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: [
@@ -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: () {
@@ -0,0 +1,24 @@
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_backend/protobuf/flowy-folder/protobuf.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) {
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,27 @@ 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 FlowyEmojiPicker extends StatefulWidget {
const FlowyEmojiPicker({
super.key,
required this.onEmojiSelected,
this.emojiPerLine = 9,
this.ensureFocus = false,
});
final EmojiSelectedCallback 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 +39,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 +52,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 +64,19 @@ 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(id, 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 +84,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
radius: Corners.s8Border,
text: FlowyTooltip(
message: name,
preferBelow: false,
child: FlowyText.emoji(
emoji,
fontSize: 24.0,
@@ -92,7 +98,8 @@ 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;
},
@@ -105,4 +112,24 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
},
);
}
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,40 @@
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';
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});
const MobileEmojiPickerScreen({
super.key,
this.title,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
final String? title;
final List<PickerTabType> tabs;
static const routeName = '/emoji_picker';
static const pageTitle = 'title';
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,
onSelectedEmoji: (r) {
context.pop<EmojiIconData>(r);
},
),
),
);
}
}
@@ -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,
);
}
}
@@ -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,
@@ -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();
},
),
],
@@ -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();
},
);
@@ -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);
@@ -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();
@@ -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> {
);
}
void forceReloadDocumentState() {
_documentCollabAdapter.syncV3();
}
// this is only used for debug mode
Future<void> checkDocumentIntegrity() async {
if (!enableDocumentInternalLog) {
@@ -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;
@@ -24,7 +24,7 @@ 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 +183,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 +212,7 @@ extension on InsertOperation {
// sync the text id to the node
node.externalValues = ExternalValues(
externalId: textId,
externalType: _kExternalTextType,
externalType: kExternalTextType,
);
}
@@ -222,7 +221,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
@@ -323,7 +322,7 @@ extension on UpdateOperation {
node.externalValues = ExternalValues(
externalId: textId,
externalType: _kExternalTextType,
externalType: kExternalTextType,
);
if (enableDocumentInternalLog) {
@@ -333,7 +332,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(
@@ -358,7 +357,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(
@@ -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,16 @@ 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) => _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
),
textAlign: (node) => _buildTextAlignInTableCell(
context,
node: node,
configuration: configuration,
),
),
showPlaceholder: showParagraphPlaceholder,
);
@@ -404,14 +421,16 @@ 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) => _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
),
textAlign: (node) => _buildTextAlignInTableCell(
context,
node: node,
configuration: configuration,
),
),
iconBuilder: (_, node, onCheck) => TodoListIcon(
node: node,
@@ -432,14 +451,16 @@ 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) => _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
),
textAlign: (node) => _buildTextAlignInTableCell(
context,
node: node,
configuration: configuration,
),
),
iconBuilder: (_, node) => BulletedListIcon(node: node),
);
@@ -452,14 +473,16 @@ 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) => _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
),
textAlign: (node) => _buildTextAlignInTableCell(
context,
node: node,
configuration: configuration,
),
),
iconBuilder: (_, node, textDirection) {
TextStyle? textStyle;
@@ -484,14 +507,16 @@ 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) => _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
),
textAlign: (node) => _buildTextAlignInTableCell(
context,
node: node,
configuration: configuration,
),
),
);
}
@@ -514,9 +539,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,6 +561,11 @@ HeadingBlockComponentBuilder _buildHeadingBlockComponentBuilder(
args: [level.toString()],
);
},
textAlign: (node) => _buildTextAlignInTableCell(
context,
node: node,
configuration: configuration,
),
),
textStyleBuilder: (level) => styleCustomizer.headingStyleBuilder(level),
);
@@ -637,7 +675,22 @@ 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) => _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
),
),
inlinePadding: const EdgeInsets.symmetric(vertical: 8.0),
defaultColor: calloutBGColor,
@@ -677,6 +730,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 +769,31 @@ 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) {
final textStyle = _buildTextStyleInTableCell(
context,
node: node,
configuration: configuration,
);
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) {
@@ -788,12 +853,63 @@ 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) => styleCustomizer.subPageBlockTextStyleBuilder(),
),
);
}
TextStyle _buildTextStyleInTableCell(
BuildContext context, {
required Node node,
required BlockComponentConfiguration configuration,
}) {
TextStyle textStyle = configuration.textStyle(node);
if (node.isInHeaderColumn ||
node.isInHeaderRow ||
node.isInBoldColumn ||
node.isInBoldRow) {
textStyle = textStyle.copyWith(
fontWeight: FontWeight.bold,
);
}
final cellTextColor = node.textColorInColumn ?? node.textColorInRow;
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;
}
@@ -1,11 +1,11 @@
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_backend/protobuf/flowy-folder/view.pb.dart';
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/shared/patterns/file_type_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:desktop_drop/desktop_drop.dart';
@@ -37,12 +37,34 @@ 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(),
onMove: (details) {
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 +90,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 +100,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 +133,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() {
@@ -574,7 +548,11 @@ Color? buildEditorCustomizedColor(
return AFThemeExtension.of(context).tableCellBGColor;
}
return null;
try {
return colorString.tryToColor();
} catch (e) {
return null;
}
}
bool showInAnyTextType(EditorState editorState) {
@@ -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);
@@ -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
@@ -122,7 +122,7 @@ class _DraggleOptionButtonFeedbackState
}
void _setupLockComponentContext() {
node = widget.blockComponentContext.node.copyWith();
node = widget.blockComponentContext.node.deepCopy();
blockComponentContext = BlockComponentContext(
widget.blockComponentContext.buildContext,
node,
@@ -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);
}
@@ -91,7 +91,7 @@ class MobileBlockActionButtons extends StatelessWidget {
case BlockActionBottomSheetType.duplicate:
transaction.insertNode(
node.path.next,
node.copyWith(),
node.deepCopy(),
);
break;
case BlockActionBottomSheetType.insertAbove:
@@ -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,
@@ -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);
}
}
}
@@ -46,6 +46,7 @@ enum EditorOptionActionType {
case EditorOptionActionType.align:
return {
ImageBlockKeys.type,
SimpleTableBlockKeys.type,
};
case EditorOptionActionType.depth:
return {
@@ -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,11 @@ 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(EmojiIconData emoji, PopoverController? controller)
onSubmitted;
final PopoverController popoverController = PopoverController();
final Widget? defaultIcon;
final Offset? offset;
@@ -81,10 +82,11 @@ 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(EmojiIconData emoji, PopoverController? controller)
onSubmitted;
final PopoverController popoverController = PopoverController();
final Widget? defaultIcon;
final Offset? offset;
@@ -93,8 +95,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 +112,10 @@ 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(
onSelectedEmoji: (r) {
onSubmitted(r, popoverController);
},
),
),
child: Container(
@@ -129,9 +134,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 +154,10 @@ 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(EmojiIconData emoji, PopoverController? controller)
onSubmitted;
final String? title;
final bool enable;
final EdgeInsets? margin;
@@ -162,21 +168,20 @@ 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},
).toString(),
);
if (result != null) {
onSubmitted(result.emoji, null);
onSubmitted(result, null);
}
}
: null,
@@ -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
@@ -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,
@@ -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,6 +256,7 @@ class _CalloutBlockComponentWidgetState
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
textAlign: alignment?.toTextAlign ?? textAlign,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
textStyle,
),
@@ -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(
@@ -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),
);
}
@@ -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),
@@ -8,21 +8,17 @@ import 'package:super_clipboard/super_clipboard.dart';
/// Used for in-app copy and paste without losing the format.
///
/// It's a Json string representing the copied editor nodes.
final inAppJsonFormat = CustomValueFormat<String>(
const inAppJsonFormat = CustomValueFormat<String>(
applicationId: 'io.appflowy.InAppJsonType',
onDecode: (value, platformType) async {
if (value is PlatformDataProvider) {
final data = await value.getData(platformType);
if (data is List<int>) {
return utf8.decode(data, allowMalformed: true);
}
if (data is String) {
return Uri.decodeFull(data);
}
}
return null;
},
onEncode: (value, platformType) => utf8.encode(value),
onDecode: _defaultDecode,
onEncode: _defaultEncode,
);
/// Used for table nodes when coping a row or a column.
const tableJsonFormat = CustomValueFormat<String>(
applicationId: 'io.appflowy.TableJsonType',
onDecode: _defaultDecode,
onEncode: _defaultEncode,
);
class ClipboardServiceData {
@@ -31,12 +27,37 @@ class ClipboardServiceData {
this.html,
this.image,
this.inAppJson,
this.tableJson,
});
/// The [plainText] is the plain text string.
///
/// It should be used for pasting the plain text from the clipboard.
final String? plainText;
/// The [html] is the html string.
///
/// It should be used for pasting the html from the clipboard.
/// For example, copy the content in the browser, and paste it in the editor.
final String? html;
/// The [image] is the image data.
///
/// It should be used for pasting the image from the clipboard.
/// For example, copy the image in the browser or other apps, and paste it in the editor.
final (String, Uint8List?)? image;
/// The [inAppJson] is the json string of the editor nodes.
///
/// It should be used for pasting the content in-app.
/// For example, pasting the content from document A to document B.
final String? inAppJson;
/// The [tableJson] is the json string of the table nodes.
///
/// It only works for the table nodes when coping a row or a column.
/// Don't use it for another scenario.
final String? tableJson;
}
class ClipboardService {
@@ -52,6 +73,7 @@ class ClipboardService {
final html = data.html;
final inAppJson = data.inAppJson;
final image = data.image;
final tableJson = data.tableJson;
final item = DataWriterItem();
if (plainText != null) {
@@ -63,6 +85,9 @@ class ClipboardService {
if (inAppJson != null) {
item.add(inAppJsonFormat(inAppJson));
}
if (tableJson != null) {
item.add(tableJsonFormat(tableJson));
}
if (image != null && image.$2?.isNotEmpty == true) {
switch (image.$1) {
case 'png':
@@ -106,6 +131,7 @@ class ClipboardService {
final plainText = await reader.readValue(Formats.plainText);
final html = await reader.readValue(Formats.htmlText);
final inAppJson = await reader.readValue(inAppJsonFormat);
final tableJson = await reader.readValue(tableJsonFormat);
(String, Uint8List?)? image;
if (reader.canProvide(Formats.png)) {
image = ('png', await reader.readFile(Formats.png));
@@ -122,6 +148,7 @@ class ClipboardService {
html: html,
image: image,
inAppJson: inAppJson,
tableJson: tableJson,
);
}
}
@@ -149,3 +176,22 @@ extension on DataReader {
return c.future;
}
}
/// The default decode function for the clipboard service.
Future<String?> _defaultDecode(Object value, String platformType) async {
if (value is PlatformDataProvider) {
final data = await value.getData(platformType);
if (data is List<int>) {
return utf8.decode(data, allowMalformed: true);
}
if (data is String) {
return Uri.decodeFull(data);
}
}
return null;
}
/// The default encode function for the clipboard service.
Future<Object> _defaultEncode(String value, String platformType) async {
return utf8.encode(value);
}
@@ -1,11 +1,11 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
/// Copy.
///
@@ -50,7 +50,7 @@ KeyEventResult handleCopyCommand(
// in app json
final document = Document.blank()
..insert([0], [_handleNode(node.copyWith(), isCut)]);
..insert([0], [_handleNode(node.deepCopy(), isCut)]);
inAppJson = jsonEncode(document.toJson());
// html
@@ -107,7 +107,7 @@ Document _buildCopiedDocument(
final document = Document.blank()
..insert(
[0],
filteredNodes.map((e) => e.copyWith()),
filteredNodes.map((e) => e.deepCopy()),
);
return document;
}
@@ -123,7 +123,7 @@ List<Node> _handleSubPageNodes(List<Node> nodes, [bool isCut = false]) {
Node _handleNode(Node node, [bool isCut = false]) {
if (!isCut) {
return node.copyWith();
return node.deepCopy();
}
final newChildren = node.children.map(_handleNode).toList();
@@ -29,6 +29,14 @@ final CommandShortcutEvent customPasteCommand = CommandShortcutEvent(
handler: _pasteCommandHandler,
);
final CommandShortcutEvent customPastePlainTextCommand = CommandShortcutEvent(
key: 'paste the plain content',
getDescription: () => AppFlowyEditorL10n.current.cmdPasteContent,
command: 'ctrl+shift+v',
macOSCommand: 'cmd+shift+v',
handler: _pastePlainCommandHandler,
);
CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
final selection = editorState.selection;
if (selection == null) {
@@ -45,6 +53,22 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
return KeyEventResult.handled;
};
CommandShortcutEventHandler _pastePlainCommandHandler = (editorState) {
final selection = editorState.selection;
if (selection == null) {
return KeyEventResult.ignored;
}
doPlainPaste(editorState).then((_) {
final context = editorState.document.root.context;
if (context != null && context.mounted) {
context.read<ClipboardState>().didPaste();
}
});
return KeyEventResult.handled;
};
Future<void> doPaste(EditorState editorState) async {
final selection = editorState.selection;
if (selection == null) {
@@ -119,7 +143,7 @@ Future<void> doPaste(EditorState editorState) async {
}
if (plainText != null && plainText.isNotEmpty) {
await editorState.pastePlainText(plainText);
await editorState.pasteText(plainText);
return Log.info('Pasted plain text');
}
@@ -190,3 +214,24 @@ Future<bool> _pasteAsLinkPreview(
return true;
}
Future<void> doPlainPaste(EditorState editorState) async {
final selection = editorState.selection;
if (selection == null) {
return;
}
EditorNotification.paste().post();
// dispatch the paste event
final data = await getIt<ClipboardService>().getData();
final plainText = data.plainText;
if (plainText != null && plainText.isNotEmpty) {
await editorState.pastePlainText(plainText);
Log.info('Pasted plain text');
return;
}
Log.info('unable to parse the clipboard content');
return;
}
@@ -1,14 +1,10 @@
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
extension PasteFromPlainText on EditorState {
Future<void> pastePlainText(String plainText) async {
if (await pasteHtmlIfAvailable(plainText)) {
return;
}
await deleteSelectionIfNeeded();
final nodes = plainText
.split('\n')
.map(
@@ -16,14 +12,7 @@ extension PasteFromPlainText on EditorState {
..replaceAll(r'\r', '')
..trimRight(),
)
.map((e) {
// parse the url content
final Attributes attributes = {};
if (hrefRegex.hasMatch(e)) {
attributes[AppFlowyRichTextKeys.href] = e;
}
return Delta()..insert(e, attributes: attributes);
})
.map((e) => Delta()..insert(e))
.map((e) => paragraphNode(delta: e))
.toList();
if (nodes.isEmpty) {
@@ -36,6 +25,24 @@ extension PasteFromPlainText on EditorState {
}
}
Future<void> pasteText(String plainText) async {
if (await pasteHtmlIfAvailable(plainText)) {
return;
}
await deleteSelectionIfNeeded();
final nodes = customMarkdownToDocument(plainText).root.children;
if (nodes.isEmpty) {
return;
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
}
Future<bool> pasteHtmlIfAvailable(String plainText) async {
final selection = this.selection;
if (selection == null ||
@@ -7,7 +7,6 @@ import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/icon/icon_selector.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
@@ -27,6 +26,8 @@ import 'package:flowy_infra_ui/widget/ignore_parent_gesture.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
double kDocumentCoverHeight = 98.0;
double kDocumentTitlePadding = 20.0;
@@ -199,7 +200,7 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
);
}
Widget _buildIcon(BuildContext context, String icon) {
Widget _buildIcon(BuildContext context, EmojiIconData icon) {
return GestureDetector(
child: ConstrainedBox(
constraints: const BoxConstraints.tightFor(width: 34.0),
@@ -215,28 +216,23 @@ class _DocumentImmersiveCoverState extends State<DocumentImmersiveCover> {
context,
showDragHandle: true,
showDivider: false,
showDoneButton: true,
showHeader: true,
title: LocaleKeys.titleBar_pageIcon.tr(),
backgroundColor: AFThemeExtension.of(context).background,
enableDraggableScrollable: true,
minChildSize: 0.6,
initialChildSize: 0.61,
showRemoveButton: true,
onRemove: () {
pageStyleIconBloc.add(
const PageStyleIconEvent.updateIcon('', true),
);
},
scrollableWidgetBuilder: (_, controller) {
return BlocProvider.value(
value: pageStyleIconBloc,
child: Expanded(
child: Scrollbar(
controller: controller,
child: IconSelector(
scrollController: controller,
),
child: FlowyIconEmojiPicker(
onSelectedEmoji: (r) {
pageStyleIconBloc.add(
PageStyleIconEvent.updateIcon(r, true),
);
Navigator.pop(context);
},
),
),
);
@@ -5,6 +5,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 'document_immersive_cover_bloc.freezed.dart';
class DocumentImmersiveCoverBloc
@@ -20,7 +22,7 @@ class DocumentImmersiveCoverBloc
add(
DocumentImmersiveCoverEvent.updateCoverAndIcon(
view.cover,
view.icon.value,
EmojiIconData.fromViewIconPB(view.icon),
view.name,
),
);
@@ -29,7 +31,7 @@ class DocumentImmersiveCoverBloc
add(
DocumentImmersiveCoverEvent.updateCoverAndIcon(
view.cover,
view.icon.value,
EmojiIconData.fromViewIconPB(view.icon),
view.name,
),
);
@@ -63,9 +65,10 @@ class DocumentImmersiveCoverBloc
@freezed
class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent {
const factory DocumentImmersiveCoverEvent.initial() = Initial;
const factory DocumentImmersiveCoverEvent.updateCoverAndIcon(
PageStyleCover? cover,
String? icon,
EmojiIconData? icon,
String? name,
) = UpdateCoverAndIcon;
}
@@ -73,7 +76,7 @@ class DocumentImmersiveCoverEvent with _$DocumentImmersiveCoverEvent {
@freezed
class DocumentImmersiveCoverState with _$DocumentImmersiveCoverState {
const factory DocumentImmersiveCoverState({
@Default(null) String? icon,
@Default(null) EmojiIconData? icon,
required PageStyleCover cover,
@Default('') String name,
}) = _DocumentImmersiveCoverState;
@@ -160,7 +160,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile
? null
: context.read<EditorDropManagerState>();
: context.read<EditorDropManagerState?>();
final fileKey = GlobalKey();
final showActionsNotifier = ValueNotifier<bool>(false);
@@ -176,7 +176,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
@override
void didChangeDependencies() {
if (!UniversalPlatform.isMobile) {
dropManagerState = context.read<EditorDropManagerState>();
dropManagerState = context.read<EditorDropManagerState?>();
}
super.didChangeDependencies();
}
@@ -281,9 +281,13 @@ class FileBlockComponentState extends State<FileBlockComponent>
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [BlockSelectionType.block],
child: Padding(key: fileKey, padding: padding, child: child),
child: Padding(
key: fileKey,
padding: padding,
child: child,
),
);
} else if (url == null || url.isEmpty) {
} else {
return Padding(
key: fileKey,
padding: padding,
@@ -384,6 +388,9 @@ class FileBlockComponentState extends State<FileBlockComponent>
),
const HSpace(8),
],
if (UniversalPlatform.isMobile) ...[
const HSpace(36),
],
];
} else {
return [
@@ -77,7 +77,7 @@ class DocumentCoverWidget extends StatefulWidget {
final Node node;
final EditorState editorState;
final void Function(String icon) onIconChanged;
final ValueChanged<EmojiIconData> onIconChanged;
final ViewPB view;
@override
@@ -88,16 +88,22 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
CoverType get coverType => CoverType.fromString(
widget.node.attributes[DocumentHeaderBlockKeys.coverType],
);
String? get coverDetails =>
widget.node.attributes[DocumentHeaderBlockKeys.coverDetails];
String? get icon => widget.node.attributes[DocumentHeaderBlockKeys.icon];
bool get hasIcon => viewIcon.isNotEmpty;
bool get hasIcon => viewIcon.emoji.isNotEmpty;
bool get hasCover =>
coverType != CoverType.none ||
(cover != null && cover?.type != PageStyleCoverImageType.none);
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
String viewIcon = '';
EmojiIconData viewIcon = EmojiIconData.none();
PageStyleCover? cover;
late ViewPB view;
late final ViewListener viewListener;
@@ -114,8 +120,8 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
@override
void initState() {
super.initState();
final value = widget.view.icon.value;
viewIcon = value.isNotEmpty ? value : icon ?? '';
final icon = widget.view.icon;
viewIcon = EmojiIconData.fromViewIconPB(icon);
cover = widget.view.cover;
view = widget.view;
widget.node.addListener(_reload);
@@ -126,7 +132,7 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
..start(
onViewUpdated: (view) {
setState(() {
viewIcon = view.icon.value;
viewIcon = EmojiIconData.fromViewIconPB(view.icon);
cover = view.cover;
view = view;
});
@@ -268,7 +274,10 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
return height;
}
void _saveIconOrCover({(CoverType, String?)? cover, String? icon}) async {
void _saveIconOrCover({
(CoverType, String?)? cover,
EmojiIconData? icon,
}) async {
final transaction = widget.editorState.transaction;
final coverType = widget.node.attributes[DocumentHeaderBlockKeys.coverType];
final coverDetails =
@@ -285,7 +294,7 @@ class _DocumentCoverWidgetState extends State<DocumentCoverWidget> {
attributes[DocumentHeaderBlockKeys.coverDetails] = cover.$2;
}
if (icon != null) {
attributes[DocumentHeaderBlockKeys.icon] = icon;
attributes[DocumentHeaderBlockKeys.icon] = icon.emoji;
widget.onIconChanged(icon);
}
@@ -337,7 +346,7 @@ class DocumentHeaderToolbar extends StatefulWidget {
final EditorState editorState;
final bool hasCover;
final bool hasIcon;
final void Function({(CoverType, String?)? cover, String? icon})
final void Function({(CoverType, String?)? cover, EmojiIconData? icon})
onIconOrCoverChanged;
final double offset;
final ValueNotifier<bool> isCoverTitleHovered;
@@ -416,7 +425,7 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
if (widget.hasIcon) {
children.add(
FlowyButton(
onTap: () => widget.onIconOrCoverChanged(icon: ""),
onTap: () => widget.onIconOrCoverChanged(icon: EmojiIconData.none()),
useIntrinsicWidth: true,
leftIcon: const FlowySvg(FlowySvgs.add_icon_s),
iconPadding: 4.0,
@@ -438,11 +447,11 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
onTap: UniversalPlatform.isDesktop
? null
: () async {
final result = await context.push<EmojiPickerResult>(
final result = await context.push<EmojiIconData>(
MobileEmojiPickerScreen.routeName,
);
if (result != null) {
widget.onIconOrCoverChanged(icon: result.emoji);
widget.onIconOrCoverChanged(icon: result);
}
},
);
@@ -460,7 +469,7 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
isPopoverOpen = true;
return FlowyIconEmojiPicker(
onSelectedEmoji: (result) {
widget.onIconOrCoverChanged(icon: result.emoji);
widget.onIconOrCoverChanged(icon: result);
_popoverController.close();
},
);
@@ -817,8 +826,8 @@ class DocumentIcon extends StatefulWidget {
final Node node;
final EditorState editorState;
final String icon;
final void Function(String icon) onChangeIcon;
final EmojiIconData icon;
final ValueChanged<EmojiIconData> onChangeIcon;
@override
State<DocumentIcon> createState() => _DocumentIconState();
@@ -844,7 +853,7 @@ class _DocumentIconState extends State<DocumentIcon> {
popupBuilder: (BuildContext popoverContext) {
return FlowyIconEmojiPicker(
onSelectedEmoji: (result) {
widget.onChangeIcon(result.emoji);
widget.onChangeIcon(result);
_popoverController.close();
},
);
@@ -854,11 +863,11 @@ class _DocumentIconState extends State<DocumentIcon> {
child = GestureDetector(
child: child,
onTap: () async {
final result = await context.push<EmojiPickerResult>(
final result = await context.push<EmojiIconData>(
MobileEmojiPickerScreen.routeName,
);
if (result != null) {
widget.onChangeIcon(result.emoji);
widget.onChangeIcon(result);
}
},
);
@@ -1,6 +1,13 @@
import 'dart:convert';
import 'package:appflowy/plugins/base/emoji/emoji_text.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy_backend/log.dart';
import 'package:flutter/material.dart';
import '../../../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import '../../../../base/icon/icon_widget.dart';
class EmojiIconWidget extends StatefulWidget {
const EmojiIconWidget({
super.key,
@@ -8,7 +15,7 @@ class EmojiIconWidget extends StatefulWidget {
this.emojiSize = 60,
});
final String emoji;
final EmojiIconData emoji;
final double emojiSize;
@override
@@ -32,10 +39,9 @@ class _EmojiIconWidgetState extends State<EmojiIconWidget> {
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: EmojiText(
child: RawEmojiIconWidget(
emoji: widget.emoji,
fontSize: widget.emojiSize,
textAlign: TextAlign.center,
emojiSize: widget.emojiSize,
),
),
);
@@ -48,3 +54,44 @@ class _EmojiIconWidgetState extends State<EmojiIconWidget> {
});
}
}
class RawEmojiIconWidget extends StatelessWidget {
const RawEmojiIconWidget({
super.key,
required this.emoji,
required this.emojiSize,
});
final EmojiIconData emoji;
final double emojiSize;
@override
Widget build(BuildContext context) {
final defaultEmoji = EmojiText(
emoji: '❓',
fontSize: emojiSize,
textAlign: TextAlign.center,
);
try {
switch (emoji.type) {
case FlowyIconType.emoji:
return EmojiText(
emoji: emoji.emoji,
fontSize: emojiSize,
textAlign: TextAlign.center,
);
case FlowyIconType.icon:
final iconData = IconsData.fromJson(jsonDecode(emoji.emoji));
return IconWidget(
data: iconData,
size: emojiSize,
);
default:
return defaultEmoji;
}
} catch (e) {
Log.error("Display widget error: $e");
return defaultEmoji;
}
}
}
@@ -73,7 +73,7 @@ final headingsToolbarItem = ToolbarItem(
blockComponentDelta: delta,
},
);
final children = node.children.map((child) => child.copyWith());
final children = node.children.map((child) => child.deepCopy());
final transaction = editorState.transaction;
transaction.insertNodes(
@@ -1,159 +0,0 @@
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_search_text_field.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon_bloc.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_emoji_mart/flutter_emoji_mart.dart';
class IconSelector extends StatefulWidget {
const IconSelector({
super.key,
required this.scrollController,
});
final ScrollController scrollController;
@override
State<IconSelector> createState() => _IconSelectorState();
}
class _IconSelectorState extends State<IconSelector> {
EmojiData? emojiData;
List<String> availableEmojis = [];
PageStyleIconBloc? pageStyleIconBloc;
@override
void initState() {
super.initState();
// load the emoji data from cache if it's available
if (kCachedEmojiData != null) {
emojiData = kCachedEmojiData;
availableEmojis = _setupAvailableEmojis(emojiData!);
} else {
EmojiData.builtIn().then(
(value) {
kCachedEmojiData = value;
setState(() {
emojiData = value;
availableEmojis = _setupAvailableEmojis(value);
});
},
);
}
pageStyleIconBloc = context.read<PageStyleIconBloc>();
}
@override
void dispose() {
pageStyleIconBloc?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (emojiData == null) {
return const Center(child: CircularProgressIndicator());
}
return RepaintBoundary(
child: BlocBuilder<PageStyleIconBloc, PageStyleIconState>(
builder: (_, state) => Column(
children: [
_buildSearchBar(context),
Expanded(
child: GridView.count(
crossAxisCount: 7,
controller: widget.scrollController,
children: [
for (final emoji in availableEmojis)
_buildEmoji(context, emoji, state.icon),
],
),
),
],
),
),
);
}
Widget _buildEmoji(
BuildContext context,
String emoji,
String? selectedEmoji,
) {
Widget child = SizedBox.square(
dimension: 24.0,
child: Center(
child: FlowyText.emoji(
emoji,
fontSize: 24,
),
),
);
if (emoji == selectedEmoji) {
child = Center(
child: Container(
width: 40,
height: 40,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: const BorderSide(
width: 1.40,
strokeAlign: BorderSide.strokeAlignOutside,
color: Color(0xFF00BCF0),
),
borderRadius: BorderRadius.circular(10),
),
),
child: child,
),
);
}
return GestureDetector(
onTap: () {
context.read<PageStyleIconBloc>().add(
PageStyleIconEvent.updateIcon(emoji, true),
);
},
child: child,
);
}
List<String> _setupAvailableEmojis(EmojiData emojiData) {
final categories = emojiData.categories;
availableEmojis = categories
.map((e) => e.emojiIds.map((e) => emojiData.getEmojiById(e)))
.expand((e) => e)
.toList();
return availableEmojis;
}
Widget _buildSearchBar(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 12.0,
),
child: FlowyMobileSearchTextField(
onChanged: (keyword) {
if (emojiData == null) {
return;
}
final filtered = emojiData!.filterByKeyword(keyword);
final availableEmojis = _setupAvailableEmojis(filtered);
setState(() {
this.availableEmojis = availableEmojis;
});
},
),
);
}
}
@@ -72,7 +72,7 @@ class _ImageMenuState extends State<ImageMenu> {
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.delete_s,
iconData: FlowySvgs.trash_s,
onTap: deleteImage,
),
const HSpace(4),
@@ -80,20 +80,18 @@ class _InlineMathEquationState extends State<InlineMathEquation> {
mathFontOptions: const FontOptions(
fontShape: FontStyle.italic,
),
fontSize: 14.0,
fontSize: widget.textStyle?.fontSize ?? 14.0,
color: widget.textStyle?.color ?? theme.colorScheme.onSurface,
),
onErrorFallback: (errmsg) {
return FlowyText(
errmsg.message,
fontSize: widget.textStyle?.fontSize ?? 14.0,
color: widget.textStyle?.color ?? theme.colorScheme.onSurface,
);
},
);
return Row(
children: [
longEq,
],
);
return longEq;
}
}
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selectable_svg_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -110,16 +111,24 @@ class MathEquationBlockComponentWidgetState
@override
Node get node => widget.node;
bool isHover = false;
String get formula =>
widget.node.attributes[MathEquationBlockKeys.formula] as String;
late final editorState = context.read<EditorState>();
final ValueNotifier<bool> isHover = ValueNotifier(false);
late final controller = TextEditingController(text: formula);
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return InkWell(
onHover: (value) => setState(() => isHover = value),
onHover: (value) => isHover.value = value,
onTap: showEditingDialog,
child: _build(context),
);
@@ -165,6 +174,23 @@ class MathEquationBlockComponentWidgetState
);
}
if (UniversalPlatform.isDesktopOrWeb) {
child = Stack(
children: [
child,
Positioned(
right: 6,
top: 12,
child: ValueListenableBuilder<bool>(
valueListenable: isHover,
builder: (_, value, __) =>
value ? _buildDeleteButton(context) : const SizedBox.shrink(),
),
),
],
);
}
return child;
}
@@ -198,8 +224,18 @@ class MathEquationBlockComponentWidgetState
);
}
Widget _buildDeleteButton(BuildContext context) {
return MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.trash_s,
onTap: () {
final transaction = editorState.transaction..deleteNode(widget.node);
editorState.apply(transaction);
},
);
}
void showEditingDialog() {
final controller = TextEditingController(text: formula);
showDialog(
context: context,
builder: (context) {
@@ -246,7 +282,7 @@ class MathEquationBlockComponentWidgetState
actionsAlignment: MainAxisAlignment.spaceAround,
);
},
).then((_) => controller.dispose());
);
}
void updateMathEquation(String mathEquation, BuildContext context) {
@@ -83,6 +83,13 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
return const SizedBox.shrink();
}
final appearance = context.read<AppearanceSettingsCubit?>();
final reminder = context.read<ReminderBloc?>();
if (appearance == null || reminder == null) {
return const SizedBox.shrink();
}
return BlocBuilder<AppearanceSettingsCubit, AppearanceSettingsState>(
buildWhen: (previous, current) =>
previous.dateFormat != current.dateFormat ||
@@ -2,11 +2,13 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/shared/clipboard_state.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
@@ -419,7 +421,6 @@ class _MentionPageBlockContent extends StatelessWidget {
child: FlowyText(
text,
decoration: TextDecoration.underline,
decorationThickness: 0.5,
fontSize: textStyle?.fontSize,
fontWeight: textStyle?.fontWeight,
lineHeight: textStyle?.height,
@@ -474,12 +475,9 @@ class _MentionPageBlockContent extends StatelessWidget {
Stack(
children: [
view.icon.value.isNotEmpty
? FlowyText.emoji(
view.icon.value,
fontSize: emojiSize,
lineHeight: textStyle?.height,
optimizeEmojiAlign: true,
color: AFThemeExtension.of(context).strongText,
? EmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: emojiSize,
)
: view.defaultIcon(size: Size.square(iconSize + 2.0)),
if (!isChildPage) ...[
@@ -3,7 +3,8 @@ 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/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/emoji/emoji_text.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/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -119,11 +120,9 @@ class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> {
final view = filtered.elementAt(index);
return FlowyOptionTile.checkbox(
leftIcon: view.icon.value.isNotEmpty
? EmojiText(
emoji: view.icon.value,
fontSize: 18,
textAlign: TextAlign.center,
lineHeight: 1.3,
? RawEmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: 18,
)
: FlowySvg(
view.layout.icon,
@@ -1,47 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
SelectionMenuItem dateMenuItem = SelectionMenuItem(
getName: LocaleKeys.document_plugins_insertDate.tr,
icon: (_, isSelected, style) => FlowySvg(
FlowySvgs.date_s,
color: isSelected
? style.selectionMenuItemSelectedIconColor
: style.selectionMenuItemIconColor,
),
keywords: ['insert date', 'date', 'time'],
handler: (editorState, menuService, context) =>
insertDateReference(editorState),
);
Future<void> insertDateReference(EditorState editorState) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return;
}
final node = editorState.getNodeAtPath(selection.end.path);
final delta = node?.delta;
if (node == null || delta == null) {
return;
}
final transaction = editorState.transaction
..replaceText(
node,
selection.start.offset,
0,
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: DateTime.now().toIso8601String(),
},
},
);
await editorState.apply(transaction);
}
Loaded 100 of 300 files, more files were not shown because too many files have changed in this diff. Show more