Compare commits

...
61 Commits
Author SHA1 Message Date
Richard Shiue 627b8ab43c fix(flutter_mobile): linked grid cannot be displayed in document (#7162) 2025-01-07 18:13:11 +08:00
Lucas.Xu 84d55b5022 fix: disable deleting mutilple nodes in table 2025-01-07 15:26:21 +08:00
Lucas.Xu b53e20f746 chore: update editor version 2025-01-07 10:21:18 +08:00
Lucas.Xu cd06161dea chore: upgrade andoird targetSdkVersion to 35 2025-01-07 09:39:39 +08:00
MornandLucas.Xu 776f70a0c7 fix: icon picker issues on mobile (#7114)
* fix: icon picker issues on mobile (#7113)

* fix: error displaying in Page style

* fix: error displaying in Favorite/Recent page

* fix: complete the filter logic of icon picker

* fix: the color picker showed when tapping down

* fix: icons are not supported in subpage blocks

* chore: add some tests

* fix: recent icons not working for grid header icon

* fix: recent icon doesn't work in space icon (#7133)

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-01-07 09:38:59 +08:00
Lucas.Xu 1d1647d58d chore: bump version 0.8.0 2025-01-06 20:55:58 +08:00
Lucas b3f94be33f fix: filter out the space when opening new tab (#7152)
* fix: filter out the space when opening tab

* test: filter out the space when opening tab
2025-01-06 17:11:18 +08:00
Lucas.Xu ace398537b fix: outline block and link preview block padding on mobile 2025-01-06 17:09:27 +08:00
Lucas 430e21aec2 fix: simple table issues and locale issues (#7138)
* fix: can't make changes on row or column of table

* fix: fallback to en-US if the locale is invalid

* chore: remove unused code

* fix: simple table issues
2025-01-06 10:18:00 +08:00
Lucas 0c1eb7306a fix: convert false value in attributes to null (#7135) 2025-01-06 10:17:51 +08:00
Lucas db11886e5f fix: simple table issues on mobile (#7115)
* fix: header row/column tap areas are too small on mobile

* test: header row/column tap areas are too small on mobile

* feat: enable auto scroll after inserting column or row

* fix: enter after emoji will create a softbreak on mobile

* fix: header row/column tap areas are too small on mobile

* fix: simple table alignment not work for item that wraps

* test: simple table alignment not work for item that wraps
2025-01-06 10:16:53 +08:00
Lucas 0d13336b32 fix: simple tests on mobile (#7102)
* fix: simple tests on mobile

* fix: subpage block padding
2025-01-06 10:16:43 +08:00
Richard Shiue 60ad397105 chore: bump dependencies (#7123)
* chore: bump dependencies

* test: fix unit test

* fix: downgrade percent indicator

* chore: flutter analyze
2025-01-03 10:11:19 +08:00
Lucas.Xu fca3189c97 fix: subpage block padding 2024-12-30 21:28:06 +08:00
Lucas e63f767926 feat: auto-dismiss collapsed handle on Android if no interaction occurs (#7088)
* feat: support auto-dismiss collapsed handle on Android

* fix: hit test area of collasepd handle is too big

* chore: upgrade appflowy_editor

* fix: simple table issues on mobile

* feat: highlight cell after insertion

* test: text color and cell background color test

* fix: sign_in_page_settings_test
2024-12-30 17:57:00 +08:00
Richard ShiueandLucas 8826e479eb fix(flutter_desktop): workspace menu ui issues (#7091)
* fix(flutter_desktop): remove log out and workspace option popovers conflict

* test: add integration test

* fix(flutter_desktop): workspace list scrollbar overlaps with list

* chore(flutter_desktop): fix padding around import from notion button

* chore(flutter_desktop): adjust popover conflict rules for workspace

* test: add integration tests

* chore(flutter_desktop): make the popoovers as barriers

* fix: regression from making the workspace item menu as barrier

* chore: update frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart

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

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2024-12-30 17:55:54 +08:00
Lucas 8e4fe3d559 chore: update changelog (#7095) 2024-12-30 17:54:30 +08:00
Richard Shiue e2ee11e48a feat: add toast messages for ai chat interactions (#7086) 2024-12-30 17:20:23 +08:00
Morn bdc0fa1f2a fix: toolbar menu not showing beacase keyboard height is not updated in time (#7060) 2024-12-30 17:20:15 +08:00
Morn 3836545682 fix: issues related to the emoji icon picker (#7063)
* fix: remove the scrolling conflict of the icon picker on macOS

* fix: the icon is not supported in sites tab

* feat: keep the icon panel open after click ramdom

* feat: the type of selector opened depends on the already set icon or emoji

* feat: the skin tone of the random emoji follows the selected skin ton

* fix: unit testing error
2024-12-30 17:20:08 +08:00
Richard Shiue 02eb0e0b83 chore(flutter_desktop): adjust toast style (#7083) 2024-12-30 17:19:56 +08:00
Lucas 38c2937f64 fix: docker ci (#7087) 2024-12-30 13:35:37 +08:00
Richard Shiue 06d5bc734b fix: try to fix message regeneration being appended instead of replaced (#7084) 2024-12-30 12:38:56 +08:00
Nathan.fooo a521541cb7 chore: only sync when doucment was changed (#7081)
* chore: only sync when doucment was changed

* chore: fmt
2024-12-30 10:26:06 +08:00
Kilu.He 7d61252e6a feat: project management & document editor features on web (#7051)
* fix: support page operations

* fix: support select language of code block

* fix: support hover controls of document

* fix: support slash panel of document

* feat: support image block and file block of document

* feat: support outline block of document

* feat: support add document on document

* feat: support create sub-page on document

* feat: support mention panel

* feat: support more actions

* feat: support create space

* fix: add some shortcuts to document

* fix: add some shortcuts to document

* fix: multiple select blocks

* fix: reupdate trash after restore and delete

* fix: lint check

* fix: callout block

* fix: view icon

* fix: colors

* fix: colors

* feat: support add grid/board/calendar

* feat: support database new layout

* fix: slash panel

* fix: bugs

* fix: grid table

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: bugs

* fix: some bugs

* fix: some bugs

* fix: invite members

* fix: styles

* fix: styles

* fix: styles

* fix: inline code

* fix: image block

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: add applink

* fix: add applink

* fix: add applink

* fix: add share link

* fix: inline link

* fix: add log

* fix: simple table bugs

* fix: simple table bugs

* fix: some bugs

* fix: build size

* fix: build size

* fix: remove private from package.json

* fix: support quick note

* fix: support quick note

* fix: support quick note

* fix: support quick note

* fix: support quick note

* fix: some bugs

* fix: support paste md

* fix: modified toolbar

* fix: modified toolbard actions

* fix: adjust editor toolbar

* fix: support billing
2024-12-30 10:12:54 +08:00
Lucas cb2b9ac537 fix: simple table issues on mobile (#7068)
* feat: use fixed height for table menu

* feat: do not close menu after enabling header column/row

* fix: remove cell background color

* fix: the text color doesn't apply to the heading block

* fix: replace quick action icon color

* fix: add done button in text color menu

* fix: don't access clipboard when opening table action menu

* fix: table navigation issues and reminder issue

* fix: cursor flash when create a row and column at the same time

* fix: unable to insert page sometimes

* fix: use default name if the title is empty

* chore: replace align icons
2024-12-30 09:23:03 +08:00
Alexandre Cirilo af31f8cf6f chore(i18n): update fr-FR translations 🐦 (#7076) 2024-12-30 09:16:52 +08:00
Richard Shiue 87c1245a3f fix: launch review issues 0.7.9 (#7073)
* fix: disable select sources when on local AI

* chore: tooltips for right sidebar actions

* chore: update selection after inserting into existing page

* fix: mobile select sources button background color

* fix: ai response metadata

* chore: don't update selected sources section until reopen

* chore: show views from all spaces in select sources menu

* chore: revert podfile changes

* chore: decrease spacing between metadata and hover actions

* chore: improve placeholder for ai message metadata
2024-12-29 21:48:37 +08:00
Nathan.fooo 49d9417cac refactor: crate dir (#7080) 2024-12-29 14:47:28 +08:00
jumbi77 1f29235538 chore(i18n): update de-DE translations 🐦 (#7071) 2024-12-28 15:39:34 +08:00
Richard Shiue fd1e36b21a fix: numbered list misalignment (#7066)
* fix: numbered list misalignment

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