Compare commits

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

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

* refactor: optimize the code

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

---------

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

* chore: apply suggestions from code review

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

* chore: apply suggestions from code review

* chore: reorganize code

* chore: i18n

* chore: enable opening the document in the sidebar

* fix: async await

* chore: rename ai message action bar widget

* feat: make transactions be reflected in the opened document

* chore: don't forget to close the bloc

* fix: isLastLineEmpty

* chore: code cleanup

* fix: sync after EditorState.apply

* chore: decrease visibility of DocumentBlocMap

* chore: add back missing assert

---------

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

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

* test: support plus menu in table cell on mobile

* feat: add lightImpact feedback

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

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

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

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

* refactor: optimize the code

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

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

---------

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

* chore: remove default slash menu items

* feat: support customizing slash commands in table cell

* fix: unable to delete math equation

* fix: underline issue

* feat: support text align in table

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

* fix: reduce the padding in table cell

* fix: drag column / row error in table

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

* chore: update test

* chore: create events

* chore: load csv

* test: add events

* test: clean

* chore: fix test

* chore: fix test

* chore: fix test

* chore: fmt

---------

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

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

* test: update table align

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

* fix: heading padding

* feat: support reordering table

* feat: highlight cell border when dragging

* chore: optimize the highlight border render logic

* fix: table menu error

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

* test: delete column / delete row

* test: delete column / delete row

* test: enable header row / header column

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

* feat: support row/column action sheet

* feat: highlight reorder button

* feat: support table action mobile

* feat: support insert row / insert column

* feat: add more actions in the table bottom sheet

* feat: implement the action logic for table action menu

* feat: support clear content

* feat: enable header column / header row

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

* fix: table cell parse error

* feat: support insert left/insert right

* feat: add delete in table action menu

* feat: support copy a table row/column

* feat: support copy paste in table cell

* feat: support cut in table

* feat: disable cut if no content in clipboard

* chore: refactor table action bottom sheet

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

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

* feat: add align in table bottom sheet

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

* feat: add align action to table action menu

* feat: integrate bold command in table bottom sheet

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

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

* chore: remove unused code

* fix: page style json parse error

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

* feat: support align in table action menu

* feat: add quick actions in table action menu

* chore: update translations

* feat: integrate copy paste in table action menu

* feat: integrate align in table action menu

* fix: flutter ci

* test: add copy/paste/cut test

* fix: align the table with the document title

* fix: flutter tests

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

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

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

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

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

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

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

* chore: restrict number of selected highest-level documents

* chore: ignore chat views

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

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

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

* chore: merge with main

* chore: remove unneeded code

* fix: sqlite migration

* fix: cargo fmt

* feat: use new api to get the member role

---------

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

* chore: find question id instead of assuming

* chore: fix clippy

* chore: show local messages if they were there

* chore: remove duplicate code

* chore: fix loading message

* chore: revert unintended translation key removal

* chore: update translation for ai service unavailable

* chore: fix initial chat message load

---------

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

* feat: mobile

* chore: disable mention page

* chore: disable select sources when on local ai

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

* chore: bump client api

* chore: fix clippy
2024-12-18 12:12:58 +08:00
Lucas 0689f4e7e1 chore: bump version 0.7.8 (#7000)
* chore: bump version 0.7.8

* chore: use --locked to install duckscript
2024-12-17 17:59:27 +08:00
Lucas 867d515a35 feat: support readonly table (#6997)
* feat: support readonly table

* fix: cannot preview pasted images

* chore: remove http schema on android
2024-12-16 17:59:38 +08:00
Richard Shiue 381d946808 chore: filter out spaces from mention page (#6994) 2024-12-16 12:25:31 +08:00
Lucas f307300b96 fix: simple table issues (#6985)
* fix: list padding in table cell is too wide

* feat: improve tab in table cell

* feat: improve shift+tab in table cell

* fix: unable to edit cell after deleting an image

* fix: inline attribute issue

* fix: disable dragging a block into table

* feat: add distribute column evenly in column action menu

* fix: numbered list icon align in table cell

* feat: add setToPageWidth and distributeColumnEvenly in table menu

* feat: support highlight color

* chore: update editor version

* test: add setToPageWidth and distributeColumnEvenly in table menu

* test: inline attribute issues

* test: add distribute column evenly in column action menu

* test: select all in table

* test:  improve tab(+shift) shortcut in table cell

* test: improve enter shortcut in table cell

* feat: keep the same column width after using distribute column widths evenly

* test: keep the same column width after using distribute column widths evenly

* test: drag block to other block's child
2024-12-16 11:47:08 +08:00
Henri Devigne d74e7b63ca chore(i18n): update fr-FR translations 🐦 (#6987) 2024-12-16 11:10:14 +08:00
uxadax 792eec7d4b chore(i18n): update de-De translations (#6984)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2024-12-16 10:12:07 +08:00
Mathias Mogensen 67e93a12e6 fix: inline grid launch review (#6992) 2024-12-16 03:05:45 +01:00
Richard Shiue e188552c1e fix: ai chat initial rag_ids shouldn't include views that are not documents or in the trash (#6982)
* chore: only add document views to initial rag_ids

* chore: filter out views that are in the trash
2024-12-13 13:04:17 +08:00
Kilu.He ebb1b6dffb fix: simple table bugs (#6981) 2024-12-13 10:34:19 +08:00
Richard Shiue 25f9bee963 fix: launch review issues 0.7.8 (#6983)
* chore: mention popup height match design

* chore: adding while scrolling through mention page popup

* chore: don't add @ and restart creating mention when already in the middle of one

* fix: hide orphan views from mention page

* fix: arrow navigation wrap mention page popup breaks bloc
2024-12-13 10:20:56 +08:00
Lucas 6a6fac7f82 chore: bump version 0.7.8 (#6978) 2024-12-12 12:50:26 +08:00
Richard Shiue 555b4b48bb fix(flutter_desktop): cannot copy ai response with ctrl c (#6976) 2024-12-12 11:57:00 +08:00
Lucas 3522569f97 feat: support universal link / app link on mobile (#6973)
* feat: support universal link/app link

* feat: support app link on android
2024-12-12 10:03:21 +08:00
Morn 699ea150ce feat: able to select language for the code block by arrow keys (#6905) (#6964) 2024-12-12 09:27:24 +08:00
Mathias Mogensen e4385adfa9 fix: rename untitled view (#6789)
* fix: rename untitled view

* test: add cloud test

* chore: clean up code

* test: use nameOrDefault

* test: fix failing test

* test: fix wrong assumption
2024-12-12 02:21:23 +01:00
Mathias Mogensen 1d46923c47 feat: shrinkWrap grid in document (#6925)
* feat: shrinkWrap grid in document

* fix: clean up code and minor fixes

* test: add test w/ load more option

* fix: reinstate pageview & clean unused code

* fix: clean database tab bar view
2024-12-12 02:21:06 +01:00
Morn 0bf706f438 fix: Esc not working for Find-Replace menu(#6955) (#6965) 2024-12-12 08:18:57 +08:00
Richard Shiue 399b7dd682 test: attempt to fix flaky test (#6970) 2024-12-12 08:16:58 +08:00
Richard Shiue e8f2940024 fix(flutter): some ai chat bugs (#6969)
* chore: add hover effect and fix radius

* chore: open ref page on mobile
2024-12-11 21:48:38 +08:00
Lucas 62d5d66d20 feat: set table to page width (#6956)
* feat: set table to page width

* feat: expand the table based on the widht percentage

* test: set to page width

* feat: distribute columns evenly

* test: distribute columns evenly

* fix: border width
2024-12-11 16:37:28 +08:00
Lucas 8b672a159f feat: support multiple blocks operation (#6958)
* feat: support multiple blocks operation

* test: support multiple blocks operation
2024-12-11 16:37:07 +08:00
uxadax d68212f4ce chore(i18n): update de-DE translations 🐦 (#6938) 2024-12-11 15:30:52 +08:00
Lucas 592390dc84 feat: support multiple lines in table cell (#6931)
* feat: support multiple lines in table cell

* feat: add document validator

* fix: unable to delete a code block in table cell

* feat: drag to expand the table row

* fix: integration test

* feat: support drag to expand the table

* fix: integration test
2024-12-10 15:20:12 +08:00
Nathan.foooandLucas.Xu d2b2f17b1c chore: diagnose sync issues with sync.log file (#6950)
* chore: filter sync log

* chore: filter sync log

* chore: enable/disable sync log

* chore: enable/disable sync log

* chore: observer document and folder

* fix: integration test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-12-10 10:02:48 +08:00
Richard Shiue 2c88653a69 feat(flutter_mobile): improve appearance of mention page selector (#6946)
* feat(flutter_mobile): improve appearance of mention page selector

* chore: move to use CustomScrollview
2024-12-09 17:20:39 +08:00
Lucas 45b4eb4b3a fix: hotfix issues for v0.7.7 (#6948)
* fix: include link preview block and file block in exported markdown

* test: include link preview block and file block in exported markdown

* chore: remove unused logs

* chore: update editor version

* fix: "+" menu should be close after pressing space

* test: cancel inline page reference menu by space

* chore: update editor version

* chore: remove unused logs
2024-12-09 16:19:37 +08:00
Richard Shiue 7b93bbe5ff fix(flutter_desktop): clicking on empty space when editing a cell sho… (#6949)
* fix(flutter_desktop): clicking on empty space when editing a cell shouldn't close event card

* test: fix integration tests
2024-12-09 15:13:37 +08:00
Ahad Patel d21c0c0dfc feat: add same delete design in database (#6620)
* add same delete design in database

* fix: remove padding when widget is null or function is null
2024-12-09 15:13:20 +08:00
Richard Shiue f5e46967ec fix(flutter): implement mention date transaction handler (#6933)
* fix: implement mention date transaction handler

* test: add integration tests

* chore: code cleanup

* chore: early return if null delta
2024-12-09 14:40:34 +08:00
Lucas e0885e2567 fix: image tests (#6928) 2024-12-09 09:17:07 +08:00
Richard Shiue 3b56887267 feat(flutter_desktop): AI chat refer to UI (#6930)
* chore: code cleanup

* feat(desktop): implement ai chat side panel

* chore: set min width for right side panel
2024-12-09 08:48:38 +08:00
Nathan.fooo b5d5312c70 chore: remove self-hosted runner (#6944) 2024-12-08 20:59:40 +08:00
Nathan.fooo 722b436cad chore: Ai chat context (#6929)
* chore: implement chat setting

* chore: clippy

* chore: rename

* chore: set rag_ids when creating a chat

* chore: clippy

* chore: fix test

* chore: fix test

* chore: fix test

* chore: clippy
2024-12-08 18:25:25 +08:00
Laura Bécognée bb50466aa9 chore(i18n): update fr-FR translations (#6936)
correct typo redémarer => redémarrer
2024-12-07 12:49:42 +08:00
Nathan.fooo da0395be5e chore: fetch rows by chunk size (#6934)
* chore: chunk size

* chore: chunk size
2024-12-07 00:02:56 +08:00
Lucas 9e82f3d7b8 fix: unable to open local file using afLaunchUrl function (#6927)
* fix: unable to open local file using afLaunchUrl function

* chore: use the latest api to open the local file

* chore: use the latest api to open the local file

* chore: use the latest api to open the local file

* test: add local paht regex test
2024-12-06 14:36:48 +08:00
Lucas 67fe0d6bfd feat: support column and row reordering in table (#6912)
* chore: update changelog

* feat: add draggable in table reorder button

* feat: support displaying text color, background color and font item in table cell

* feat: separate gestures for popup menu and drag operations

* feat: support feedback mode for table

* feat: build dummy node to render table feedback

* feat: disable column resize handle when dragging column

* feat: higtlight the cell border when dragging

* fix: unable to reorder in row

* fix: do not rebuild the reorder button when reordering

* feat: add reorder logic and tests

* feat: reorder column

* feat: reorder row

* test: reorder row

* fix: table attributes are broken after reordering

* chore: remove unused listerner

* chore: code refactor

* fix: remove unused code

* feat: support rendering table feedback

* fix: unit test
2024-12-06 09:22:32 +08:00
Nathan.fooo dddf5aa195 chore: move type option to collab repo (#6921)
* chore: bump collab

* chore: bump collab

* chore: fix test compile

* chore: fix test

* chore: remove numeric

* chore: fix media type option
2024-12-05 14:12:25 +08:00
Richard Shiue 92945cafdf fix: initial ai chat load (#6920) 2024-12-04 20:53:03 +08:00
Kilu.He 5cf6617231 fix: simple table width (#6918)
* fix: adjust min width

* fix: adjust simple table font size

* fix: do not need to run rust-ci and docker-ci when web codes have been changed
2024-12-04 16:16:48 +08:00
Richard Shiue 03c84ff8b5 feat: open ai response url source in browser (#6917) 2024-12-03 22:55:34 +08:00
Richard Shiue 0cf3ade332 fix(desktop): resize sidebar menu regression (#6897) 2024-12-03 22:20:30 +08:00
Richard Shiue 7c24b6feb0 feat: revamp mention page interactions in AI chat (#6896)
* chore: code cleanup

* chore: improve mention page ui

* chore: just use view pb

* chore: remove chat input menu style

* chore: code cleanup

* chore: rewrite and unify chat input action handler and bloc

* feat: improve appearance of mention page popup

* fix: misaligned emoji text
2024-12-03 22:20:14 +08:00
Lucas 687121ff14 chore: upgrade rust version to 1.80.1 (#6916) 2024-12-03 21:38:28 +08:00
Lucas e32c584b3b fix: remove extra padding in table on mobile (#6915) 2024-12-03 20:41:52 +08:00
Lucas 64e4416f54 chore: release version 0.7.6 (#6910)
* chore: update changelog

* feat: support displaying text color, background color and font item in table cell

* fix: integration test and unit test
2024-12-03 17:27:59 +08:00
551 changed files with 24111 additions and 9115 deletions

No files matched your search

+1 -1
View File
@@ -152,7 +152,7 @@ jobs:
rustup target install aarch64-linux-android
rustup target install x86_64-linux-android
rustup target add armv7-linux-androideabi
cargo install --force duckscript_cli
cargo install --force --locked duckscript_cli
cargo install cargo-ndk
if [ "$RUNNER_OS" == "Linux" ]; then
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
+2 -2
View File
@@ -2,9 +2,9 @@ name: Docker-CI
on:
push:
branches: ["main", "release/*"]
branches: [ "main", "release/*" ]
pull_request:
branches: ["main", "release/*"]
branches: [ "main", "release/*" ]
workflow_dispatch:
concurrency:
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
working-directory: frontend
run: |
rustup target install aarch64-apple-ios-sim
cargo install --force duckscript_cli
cargo install --force --locked duckscript_cli
cargo install cargo-lipo
cargo make appflowy-flutter-deps-tools
shell: bash
+10 -10
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
create-release:
@@ -73,8 +73,8 @@ jobs:
working-directory: frontend
run: |
vcpkg integrate install
cargo install --force cargo-make
cargo install --force duckscript_cli
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- name: Build Windows app
working-directory: frontend
@@ -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,8 +158,8 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force cargo-make
cargo install --force duckscript_cli
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- name: Build AppFlowy
working-directory: frontend
@@ -256,8 +256,8 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force cargo-make
cargo install --force duckscript_cli
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- name: Build AppFlowy
working-directory: frontend
@@ -370,8 +370,8 @@ 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 duckscript_cli
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
rustup target add ${{ matrix.job.target }}
- name: Install gcc-aarch64-linux-gnu
+2 -72
View File
@@ -18,81 +18,11 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.7.6-amd64
RUST_TOOLCHAIN: "1.77.2"
CLOUD_VERSION: 0.8.3-amd64
RUST_TOOLCHAIN: "1.80.1"
jobs:
self-hosted-job:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Checkout Appflowy Cloud
uses: actions/checkout@v4
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
- name: Prepare Appflowy Cloud env
working-directory: AppFlowy-Cloud
run: |
cp deploy.env .env
sed -i '' 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
sed -i '' 's|APPFLOWY_AI_OPENAI_API_KEY=.*|APPFLOWY_AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env
- name: Ensure AppFlowy-Cloud is Running with Correct Version
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }}
APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }}
APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }}
run: |
container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q)
if [ -z "$container_id" ]; then
echo "AppFlowy-Cloud container is not running. Pulling and starting the container..."
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
sleep 10
else
running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id")
if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then
echo "AppFlowy-Cloud is running with an incorrect version. Pulling the correct version..."
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
sleep 10
docker ps -a
docker compose logs
else
echo "AppFlowy-Cloud is running with the correct version."
fi
fi
- name: Run rust-lib tests
working-directory: frontend/rust-lib
env:
RUST_LOG: info
RUST_BACKTRACE: 1
af_cloud_test_base_url: http://localhost
af_cloud_test_ws_url: ws://localhost/ws/v1
af_cloud_test_gotrue_url: http://localhost/gotrue
run: |
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
working-directory: frontend/rust-lib/
- name: clippy rust-lib
run: cargo clippy --all-targets -- -D warnings
working-directory: frontend/rust-lib
ubuntu-job:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: Set timezone for action
+3 -3
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
tests:
@@ -40,8 +40,8 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force cargo-make
cargo install --force duckscript_cli
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- uses: Swatinem/rust-cache@v2
with:
+3 -29
View File
@@ -5,13 +5,14 @@ on:
paths:
- ".github/workflows/tauri2_ci.yaml"
- "frontend/rust-lib/**"
paths-ignore:
- "frontend/appflowy_web_app/**"
- "frontend/resources/**"
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
CARGO_MAKE_VERSION: "0.36.6"
CI: true
@@ -20,34 +21,7 @@ concurrency:
cancel-in-progress: true
jobs:
# tauri-build-self-hosted:
# if: github.event.pull_request.head.repo.full_name == github.repository
# runs-on: self-hosted
#
# steps:
# - uses: actions/checkout@v4
# - name: install frontend dependencies
# working-directory: frontend/appflowy_web_app
# run: |
# mkdir dist
# pnpm install
# cd src-tauri && cargo build
#
# - name: test and lint
# working-directory: frontend/appflowy_web_app
# run: |
# pnpm run lint:tauri
#
# - uses: tauri-apps/tauri-action@v0
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# tauriScript: pnpm tauri
# projectPath: frontend/appflowy_web_app
# args: "--debug"
tauri-build-ubuntu:
#if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-20.04
steps:
@@ -121,4 +95,4 @@ jobs:
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_web_app
args: "--debug"
args: "--debug"
+5 -5
View File
@@ -7,7 +7,7 @@ on:
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-20.04 ]
platform: [ubuntu-20.04]
runs-on: ${{ matrix.platform }}
@@ -73,7 +73,7 @@ jobs:
if: matrix.platform == 'windows-latest'
working-directory: frontend
run: |
cargo install --force duckscript_cli
cargo install --force --locked duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
@@ -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
@@ -108,4 +108,4 @@ jobs:
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_tauri
args: "--debug"
args: "--debug"
+7 -8
View File
@@ -4,20 +4,19 @@ on:
workflow_dispatch:
inputs:
branch:
description: 'The branch to release'
description: "The branch to release"
required: true
default: 'main'
default: "main"
version:
description: 'The version to release'
description: "The version to release"
required: true
default: '0.0.0'
default: "0.0.0"
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
publish-tauri:
permissions:
contents: write
@@ -84,7 +83,7 @@ jobs:
if: matrix.settings.platform == 'windows-latest'
working-directory: frontend
run: |
cargo install --force duckscript_cli
cargo install --force --locked duckscript_cli
vcpkg integrate install
- name: install dependencies (ubuntu only)
@@ -97,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
+44 -1
View File
@@ -1,4 +1,47 @@
# Release Notes
## Version 0.7.9 - 25/12/2024
### New Features
### Bug Fixes
## Version 0.7.8 - 18/12/2024
### New Features
<img width="1068" alt="image" src="https://github.com/user-attachments/assets/cf8bd287-f370-4291-8638-76e2bbf4aaac" />
- Meet Simple Table 2.0:
- Insert a list into a table cell
- Insert images, quotes, callouts, and code blocks into a table cell
- Drag to move rows or columns
- Toggle header rows or columns on/off
- Distribute columns evenly
- Adjust to page width
- Enjoy a new UI/UX for a seamless experience
- Revamped mention page interactions in AI Chat
- Improved AppFlowy AI service
### Bug Fixes
- Fixed an error when opening files in the database in local mode
- Fixed arrow up/down navigation not working for selecting a language in Code Block
- Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work
## Version 0.7.7 - 09/12/2024
### Bug Fixes
- Fixed sidebar menu resize regression
- Fixed AI chat loading issues
- Fixed inability to open local files in database
- Fixed mentions remaining in notifications after removal from document
- Fixed event card closing when clicking on empty space
- Fixed keyboard shortcut issues
## Version 0.7.6 - 03/12/2024
### New Features
- Revamped the simple table UI
- Added support for capturing images from camera on mobile
### Bug Fixes
- Improved markdown rendering capabilities in AI writer
- Fixed an issue where pressing Enter on a collapsed toggle list would add an unnecessary new line
- Fixed an issue where creating a document from slash menu could insert content at incorrect position
## Version 0.7.5 - 25/11/2024
### Bug Fixes
- Improved chat response parsing
@@ -939,4 +982,4 @@ Bug fixes and improvements
- Increased height of action
- CPU performance issue
- Fix potential data parser error
- More foundation work for online collaboration
- More foundation work for online collaboration
+1 -1
View File
@@ -20,7 +20,7 @@ workflows:
rustup target install aarch64-apple-ios-sim
cargo install --force cargo-make
cargo install --force duckscript_cli
cargo install --force --locked duckscript_cli
cargo install --force cargo-lipo
cargo make appflowy-flutter-deps-tools
+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.6"
APPFLOWY_VERSION = "0.7.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -36,7 +36,6 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:scheme="appflowy-flutter" />
<!-- <data android:host="login-callback" /> -->
</intent-filter>
</activity>
<!--
@@ -50,7 +49,8 @@
<!-- Permission to read files from external storage (outside application container).
As of Android 12 this permission no longer has any effect. Instead use the
READ_MEDIA_IMAGES, READ_MEDIA_VIDEO or READM_MEDIA_AUDIO permissions. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Permissions to read media files. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<queries>
@@ -1,16 +1,19 @@
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_move_page_test.dart' as sidebar_move_page_test;
import 'sidebar/sidebar_rename_untitled_test.dart'
as sidebar_rename_untitled_test;
import 'uncategorized/uncategorized_test_runner.dart'
as uncategorized_test_runner;
import 'workspace/workspace_test_runner.dart' as workspace_test_runner;
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
Future<void> main() async {
preset_af_cloud_env_test.main();
data_migration_test_runner.main();
// uncategorized
uncategorized_test_runner.main();
@@ -22,4 +25,5 @@ Future<void> main() async {
// sidebar
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
}
@@ -0,0 +1,55 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.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/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/constants.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Rename empty name view (untitled)', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
);
// click the ... button and open rename dialog
await tester.hoverOnPageName(
ViewLayoutPB.Document.defaultName,
onHover: () async {
await tester.tapPageOptionButton();
await tester.tapButtonWithName(
LocaleKeys.disclosureAction_rename.tr(),
);
},
);
await tester.pumpAndSettle();
expect(find.byType(NavigatorTextFieldDialog), findsOneWidget);
final textField = tester.widget<FlowyFormTextInput>(
find.descendant(
of: find.byType(NavigatorTextFieldDialog),
matching: find.byType(FlowyFormTextInput),
),
);
expect(
textField.controller!.text,
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
);
});
}
@@ -301,6 +301,7 @@ void main() {
await tester.createOption(name: "qwer");
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags");
@@ -332,6 +333,7 @@ void main() {
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
@@ -538,8 +538,8 @@ void main() {
// edit the first date cell
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.DateTime);
await tester.toggleIncludeTime();
final now = DateTime.now();
await tester.toggleIncludeTime();
await tester.selectDay(content: now.day);
await tester.dismissCellEditor();
@@ -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(
@@ -139,6 +139,22 @@ void main() {
),
findsOneWidget,
);
/// press cmd/ctrl+F to display the find menu
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyF,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
/// press esc to dismiss the find menu
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
},
);
}
@@ -2,12 +2,12 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.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';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -330,6 +330,23 @@ void main() {
expect(find.text("$_createdPageName (copy)"), findsNWidgets(2));
expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget);
});
testWidgets('Cancel inline page reference menu by space', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showPlusMenu();
// Cancel by space
await tester.simulateKeyEvent(
LogicalKeyboardKey.space,
);
await tester.pumpAndSettle();
expect(find.byType(InlineActionsMenu), findsNothing);
});
});
}
@@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -11,7 +13,14 @@ import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('cover image:', () {
testWidgets('document cover tests', (tester) async {
@@ -147,7 +156,7 @@ void main() {
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
punch,
EmojiIconData.emoji(punch),
);
});
});
@@ -1,12 +1,15 @@
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/row/row.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart';
import 'package:appflowy/plugins/inline_actions/widgets/inline_actions_handler.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -174,9 +177,110 @@ void main() {
findsOneWidget,
);
});
testWidgets('insert a referenced grid with many rows (load more option)',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
// validate the referenced grid is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(GridPage),
),
findsOneWidget,
);
// https://github.com/AppFlowy-IO/AppFlowy/issues/3533
// test: the selection of editor should be clear when editing the grid
await tester.editor.updateSelection(
Selection.collapsed(
Position(path: [1]),
),
);
final gridTextCell = find.byType(EditableTextCell).first;
await tester.tapButton(gridTextCell);
expect(tester.editor.getCurrentEditorState().selection, isNull);
final editorScrollable = find
.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byWidgetPredicate(
(w) => w is Scrollable && w.axis == Axis.vertical,
),
)
.first;
// Add 100 Rows to the linked database
final addRowFinder = find.byType(GridAddRowButton);
for (var i = 0; i < 100; i++) {
await tester.scrollUntilVisible(
addRowFinder,
100,
scrollable: editorScrollable,
);
await tester.tapButton(addRowFinder);
await tester.pumpAndSettle();
}
// Since all rows visible are those we added, we should see all of them
expect(find.byType(GridRow), findsNWidgets(103));
// Navigate to getting started
await tester.openPage(gettingStarted);
// Navigate back to the document
await tester.openPage('insert_a_reference_${ViewLayoutPB.Grid.name}');
// We see only 25 Grid Rows
expect(find.byType(GridRow), findsNWidgets(25));
// We see Add row and load more button
expect(find.byType(GridAddRowButton), findsOneWidget);
expect(find.byType(GridRowLoadMoreButton), findsOneWidget);
// Load more rows, expect 50 visible
await _loadMoreRows(tester, editorScrollable, 50);
// Load more rows, expect 75 visible
await _loadMoreRows(tester, editorScrollable, 75);
// Load more rows, expect 100 visible
await _loadMoreRows(tester, editorScrollable, 100);
// Load more rows, expect 103 visible
await _loadMoreRows(tester, editorScrollable, 103);
// We no longer see load more option
expect(find.byType(GridRowLoadMoreButton), findsNothing);
});
});
}
Future<void> _loadMoreRows(
WidgetTester tester,
Finder scrollable, [
int? expectedRows,
]) async {
await tester.scrollUntilVisible(
find.byType(GridRowLoadMoreButton),
100,
scrollable: scrollable,
);
await tester.pumpAndSettle();
await tester.tap(find.byType(GridRowLoadMoreButton));
await tester.pumpAndSettle();
if (expectedRows != null) {
expect(find.byType(GridRow), findsNWidgets(expectedRows));
}
}
/// Insert a referenced database of [layout] into the document
Future<void> insertLinkedDatabase(
WidgetTester tester,
@@ -1,14 +1,21 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../shared/util.dart';
@@ -18,7 +25,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('date or reminder block in document', () {
group('date or reminder block in document:', () {
testWidgets("insert date with time block", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -121,5 +128,339 @@ void main() {
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
});
testWidgets("copy, cut and paste a date mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'copy, cut and paste a date mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final DateTime currentDateTime = DateTime.now();
final String formattedDate =
dateTimeSettings.dateFormat.formatDate(currentDateTime, false);
// get current date in editor
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
// update selection and copy
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: 1),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
// update selection and cut
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: 1),
end: Position(path: [0], offset: 2),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
});
testWidgets("copy, cut and paste a reminder mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'copy, cut and paste a reminder mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
// trigger popup
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(TableCalendar),
matching: find.text(15.toString()),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// add a reminder
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// verify
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and copy
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: 1),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2));
expect(
getIt<ReminderBloc>().state.reminders.map((e) => e.id).toSet().length,
2,
);
// update selection and cut
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: 1),
end: Position(path: [0], offset: 2),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2));
expect(
getIt<ReminderBloc>().state.reminders.map((e) => e.id).toSet().length,
2,
);
});
testWidgets("delete, undo and redo a reminder mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'delete, undo and redo a reminder mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
// trigger popup
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(TableCalendar),
matching: find.text(15.toString()),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// add a reminder
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// verify
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and backspace to delete the mention
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNothing);
expect(find.text('@$formattedDate'), findsNothing);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing);
expect(getIt<ReminderBloc>().state.reminders.isEmpty, isTrue);
// undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
isShiftPressed: true,
);
expect(find.byType(MentionDateBlock), findsNothing);
expect(find.text('@$formattedDate'), findsNothing);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing);
expect(getIt<ReminderBloc>().state.reminders.isEmpty, isTrue);
});
});
}
@@ -24,13 +24,6 @@ import 'package:run_with_network_images/run_with_network_images.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
const _testImageUrls = [
'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',
'https://www.easygifanimator.net/images/samples/eglite.gif',
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',
];
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
@@ -138,11 +131,12 @@ void main() {
file.deleteSync();
});
for (final url in _testImageUrls) {
testWidgets('insert an image from network: $url', (tester) async {
await testEmbedImage(tester, url);
});
}
testWidgets('insert a gif image from network', (tester) async {
await testEmbedImage(
tester,
'https://www.easygifanimator.net/images/samples/sparkles.gif',
);
});
testWidgets('insert an image from unsplash', (tester) async {
await runWithNetworkImages(() async {
@@ -113,5 +113,55 @@ void main() {
tester.expectToSeeText(formula);
});
testWidgets('insert a inline math equation and type something after it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'math equation',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
const formula = 'E = MC ^ 2';
await tester.ime.insertText(formula);
await tester.editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// tap the inline math equation button
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
final inlineMathEquation = find.byType(InlineMathEquation);
expect(inlineMathEquation, findsOneWidget);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
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),
);
});
});
}
@@ -94,6 +94,20 @@ void main() {
await tester.tapButton(finder);
expect(find.byType(GridPage), findsOneWidget);
});
testWidgets('insert a inline page and type something after the page',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertInlinePage(tester, ViewLayoutPB.Grid);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
expect(find.textContaining(text, findRichText: true), findsOneWidget);
});
});
}
@@ -1,5 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -115,19 +114,41 @@ void main() {
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// hover on the table
final tableBlock = find.byType(SimpleTableBlockWidget).first;
// add a new row
final row = find.byWidgetPredicate((w) {
return w is SimpleTableRowBlockWidget && w.node.rowIndex == 1;
});
await tester.hoverOnWidget(
tableBlock,
row,
onHover: () async {
// click the add row button
final addRowButton = find.byType(SimpleTableAddRowButton).first;
await tester.tap(addRowButton);
},
);
await tester.pumpAndSettle();
// click the add column button
// add a new column
final column = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget && w.node.columnIndex == 1;
}).first;
await tester.hoverOnWidget(
column,
onHover: () async {
final addColumnButton = find.byType(SimpleTableAddColumnButton).first;
await tester.tap(addColumnButton);
},
);
await tester.pumpAndSettle();
// add a new row and a new column
final row2 = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget &&
w.node.rowIndex == 2 &&
w.node.columnIndex == 2;
}).first;
await tester.hoverOnWidget(
row2,
onHover: () async {
// click the add row and column button
final addRowAndColumnButton =
find.byType(SimpleTableAddColumnAndRowButton).first;
@@ -311,6 +332,398 @@ void main() {
expect(tableNode.columnLength, 2);
});
});
testWidgets('set column width to page width (1)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.setToPageWidth,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, greaterThan(beforeWidth));
});
testWidgets('set column width to page width (2)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.setToPageWidth,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, greaterThan(beforeWidth));
});
testWidgets('distribute columns evenly (1)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.distributeColumnsEvenly,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, equals(beforeWidth));
final distributeColumnWidthsEvenly =
tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly];
expect(distributeColumnWidthsEvenly, isTrue);
});
testWidgets('distribute columns evenly (2)', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final tableNode = tester.editor.getNodeAtPath([0]);
final beforeWidth = tableNode.width;
// set the column width to page width
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.distributeColumnsEvenly,
);
await tester.pumpAndSettle();
final afterWidth = tableNode.width;
expect(afterWidth, equals(beforeWidth));
final distributeColumnWidthsEvenly =
tableNode.attributes[SimpleTableBlockKeys.distributeColumnWidthsEvenly];
expect(distributeColumnWidthsEvenly, isTrue);
});
testWidgets('using option menu to set column width', (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 beforeWidth = editorState.document.nodeAtPath([0])!.width;
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth.tr(),
),
);
await tester.pumpAndSettle();
final afterWidth = editorState.document.nodeAtPath([0])!.width;
expect(afterWidth, greaterThan(beforeWidth));
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr(),
),
);
await tester.pumpAndSettle();
final afterWidth2 = editorState.document.nodeAtPath([0])!.width;
expect(afterWidth2, equals(afterWidth));
});
testWidgets('insert a table and use select all the delete it',
(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.tapLineOfEditorAt(1);
await tester.ime.insertText('Hello World');
// select all
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isMetaPressed: UniversalPlatform.isMacOS,
isControlPressed: !UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
// only one paragraph left
expect(editorState.document.root.children.length, 1);
final paragraphNode = editorState.document.nodeAtPath([0])!;
expect(paragraphNode.delta, isNull);
});
testWidgets('use tab or shift+tab to navigate in table', (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.simulateKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final selection = editorState.selection;
expect(selection, isNotNull);
expect(selection!.start.path, [0, 0, 1, 0]);
expect(selection.end.path, [0, 0, 1, 0]);
await tester.simulateKeyEvent(
LogicalKeyboardKey.tab,
isShiftPressed: true,
);
await tester.pumpAndSettle();
final selection2 = editorState.selection;
expect(selection2, isNotNull);
expect(selection2!.start.path, [0, 0, 0, 0]);
expect(selection2.end.path, [0, 0, 0, 0]);
});
testWidgets('shift+enter to insert a new line in table', (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.simulateKeyEvent(
LogicalKeyboardKey.enter,
isShiftPressed: true,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.document.nodeAtPath([0, 0, 0])!;
expect(node.children.length, 1);
});
testWidgets('using option menu to set table align', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.hoverAndClickOptionMenuButton([0]);
final editorState = tester.editor.getCurrentEditorState();
final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(beforeAlign, TableAlign.left);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_center.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign, TableAlign.center);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_right.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign2, TableAlign.right);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_left.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign3, TableAlign.left);
});
testWidgets('using option menu to set table align', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
await tester.editor.hoverAndClickOptionMenuButton([0]);
final editorState = tester.editor.getCurrentEditorState();
final beforeAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(beforeAlign, TableAlign.left);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_center.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign, TableAlign.center);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_right.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign2 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign2, TableAlign.right);
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_simpleTable_moreActions_align.tr(),
),
);
await tester.pumpAndSettle();
await tester.tapButton(
find.text(
LocaleKeys.document_plugins_optionAction_left.tr(),
),
);
await tester.pumpAndSettle();
final afterAlign3 = editorState.document.nodeAtPath([0])!.tableAlign;
expect(afterAlign3, TableAlign.left);
});
testWidgets('support slash menu in table', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
final editorState = tester.editor.getCurrentEditorState();
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
final path = [0, 0, 0, 0];
final selection = Selection.collapsed(Position(path: path));
editorState.selection = selection;
await tester.editor.showSlashMenu();
await tester.pumpAndSettle();
final paragraphItem = find.byWidgetPredicate((w) {
return w is SelectionMenuItemWidget &&
w.item.name == LocaleKeys.document_slashMenu_name_text.tr();
});
expect(paragraphItem, findsOneWidget);
await tester.tap(paragraphItem);
await tester.pumpAndSettle();
final paragraphNode = editorState.document.nodeAtPath(path)!;
expect(paragraphNode.type, equals(ParagraphBlockKeys.type));
});
}
extension on WidgetTester {
@@ -82,7 +82,7 @@ void main() {
// reset to appflowy cloud
await tester.tapButton(
findServerType(AuthenticatorType.appflowyCloudSelfHost),
);
);
// change the server type to appflowy cloud
await tester.tapButton(
findServerType(AuthenticatorType.appflowyCloud),
@@ -1,83 +1,229 @@
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/base.dart';
import '../../shared/common_operations.dart';
import '../../shared/emoji.dart';
import '../../shared/expectation.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final emoji = EmojiIconData.emoji('😁');
const emoji = '😁';
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
group('Icon:', () {
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
tearDownAll(() {
RecentIcons.enable = true;
});
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
Future<EmojiIconData> loadIcon() async {
await loadIconGroups();
final groups = kIconGroups!;
final firstGroup = groups.first;
final firstIcon = firstGroup.icons.first;
return EmojiIconData.icon(
IconsData(
firstGroup.name,
firstIcon.content,
firstIcon.name,
builtInSpaceColors.first,
),
);
}
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
testWidgets('Update page emoji in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
});
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// update its emoji
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
testWidgets('Update page emoji in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
});
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its emoji
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Emoji Search Bar Get Focus', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
await tester.openPage(
value.name,
layout: value,
);
final title = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(value.name),
);
await tester.tapButton(title);
await tester.tapButton(find.byType(EmojiPickerButton));
final emojiPicker = find.byType(FlowyEmojiPicker);
expect(emojiPicker, findsOneWidget);
final textField = find.descendant(
of: emojiPicker,
matching: find.byType(FlowyTextField),
);
expect(textField, findsOneWidget);
final textFieldWidget =
textField.evaluate().first.widget as FlowyTextField;
assert(textFieldWidget.focusNode!.hasFocus);
await tester.tapEmoji(emoji.emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await loadIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await loadIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
}
@@ -1,5 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -38,7 +39,11 @@ void main() {
await tester.tapEmoji(emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
EmojiIconData.emoji(emoji),
);
});
});
}
@@ -0,0 +1,91 @@
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';
import 'package:flutter/services.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/document_test_operations.dart';
import '../document/document_codeblock_paste_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Code Block Language Selector Test', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// tap editor to get focus
await tester.tapButton(find.byType(AppFlowyEditor));
expect(find.byType(CodeBlockLanguageSelector), findsNothing);
await insertCodeBlockInDocument(tester);
///tap button
await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget));
await tester
.tapButtonWithName(LocaleKeys.document_codeBlock_language_auto.tr());
expect(find.byType(CodeBlockLanguageSelector), findsOneWidget);
for (var i = 0; i < 3; ++i) {
await onKey(tester, LogicalKeyboardKey.arrowDown);
}
for (var i = 0; i < 2; ++i) {
await onKey(tester, LogicalKeyboardKey.arrowUp);
}
await onKey(tester, LogicalKeyboardKey.enter);
final editorState = tester.editor.getCurrentEditorState();
String language = editorState
.getNodeAtPath([0])!
.attributes[CodeBlockKeys.language]
.toString();
expect(
language.toLowerCase(),
defaultCodeBlockSupportedLanguages.first.toLowerCase(),
);
await tester.hoverOnWidget(find.byType(CodeBlockComponentWidget));
await tester.tapButtonWithName(language);
await onKey(tester, LogicalKeyboardKey.arrowUp);
await onKey(tester, LogicalKeyboardKey.enter);
language = editorState
.getNodeAtPath([0])!
.attributes[CodeBlockKeys.language]
.toString();
expect(
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');
});
}
Future<void> onKey(WidgetTester tester, LogicalKeyboardKey key) async {
await tester.simulateKeyEvent(key);
await tester.pumpAndSettle();
}
@@ -1,6 +1,8 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
as code_language_selector;
import 'desktop/first_test/first_test.dart' as first_test;
Future<void> main() async {
@@ -11,6 +13,6 @@ Future<void> runIntegration9OnDesktop() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
first_test.main();
tabs_test.main();
code_language_selector.main();
}
@@ -2,6 +2,7 @@ import 'package:integration_test/integration_test.dart';
import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'simple_table_test.dart' as simple_table_test;
import 'title_test.dart' as title_test;
void main() {
@@ -11,4 +12,5 @@ void main() {
title_test.main();
page_style_test.main();
plus_menu_test.main();
simple_table_test.main();
}
@@ -0,0 +1,440 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('simple table:', () {
testWidgets('''
1. insert a simple table via + menu
2. insert a row above the table
3. insert a row below the table
4. insert a column left to the table
5. insert a column right to the table
6. delete the first row
7. delete the first column
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// insert left and insert right
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// insert left, insert right
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(),
),
);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertRight
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(4));
}
// insert above and insert below
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove
.tr(),
),
);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(4));
expect(table.columnLength, equals(4));
}
// delete the first row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// delete the first row
await tester.clickRowMenuButton(0);
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(3));
expect(table.columnLength, equals(4));
}
// delete the first column
{
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
await tester.clickColumnMenuButton(0);
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(3));
expect(table.columnLength, equals(3));
}
});
testWidgets('''
1. insert a simple table via + menu
2. enable header column
3. enable header row
4. set to page width
5. distribute columns evenly
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// enable header column
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn
.tr(),
),
);
}
// enable header row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(),
),
);
}
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.isHeaderColumnEnabled, isTrue);
expect(table.isHeaderRowEnabled, isTrue);
// set to page width
{
final table = editorState.getNodeAtPath([0])!;
final beforeWidth = table.width;
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth
.tr(),
),
);
// check the table is updated
expect(table.width, greaterThan(beforeWidth));
}
// distribute columns evenly
{
final table = editorState.getNodeAtPath([0])!;
final beforeWidth = table.width;
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the column menu button
await tester.clickColumnMenuButton(0);
// distribute columns evenly
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr(),
),
);
// check the table is updated
expect(table.width, equals(beforeWidth));
}
});
testWidgets('''
1. insert a simple table via + menu
2. bold
3. clear content
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
await tester.ime.insertText('Hello');
// enable bold
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// enable bold
await tester.clickSimpleTableBoldContentAction();
await tester.cancelTableActionMenu();
// check the first cell is bold
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
expect(paragraph.isInBoldColumn, isTrue);
}
// clear content
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the column menu button
await tester.clickColumnMenuButton(0);
// clear content
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the first cell is empty
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
expect(paragraph.delta!, isEmpty);
}
});
testWidgets('''
1. insert a simple table via + menu
2. insert a heading block in table cell
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// open the plus menu and select the heading block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.editor_toggleHeading1ShortForm.tr(),
);
// check the heading block is inserted
final heading = editorState.getNodeAtPath([0, 0, 0, 0])!;
expect(heading.type, equals(HeadingBlockKeys.type));
expect(heading.level, equals(1));
}
});
});
}
@@ -8,8 +8,11 @@ import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
@@ -449,11 +452,8 @@ extension CommonOperations on WidgetTester {
// open the page after created
if (openAfterCreated) {
await openPage(
// if the name is null, use the default name
pageName ?? LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
layout: layout,
);
// if the name is null, use empty string
await openPage(pageName ?? '', layout: layout);
await pumpAndSettle();
}
}
@@ -611,7 +611,7 @@ extension CommonOperations on WidgetTester {
required String name,
required String parentName,
required ViewLayoutPB layout,
required String icon,
required EmojiIconData icon,
}) async {
final iconButton = find.descendant(
of: findPageName(
@@ -623,7 +623,11 @@ extension CommonOperations on WidgetTester {
find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()),
);
await tapButton(iconButton);
await tapEmoji(icon);
if (icon.type == FlowyIconType.emoji) {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
}
await pumpAndSettle();
}
@@ -631,7 +635,7 @@ extension CommonOperations on WidgetTester {
Future<void> updatePageIconInTitleBarByName({
required String name,
required ViewLayoutPB layout,
required String icon,
required EmojiIconData icon,
}) async {
await openPage(
name,
@@ -643,7 +647,11 @@ extension CommonOperations on WidgetTester {
);
await tapButton(title);
await tapButton(find.byType(EmojiPickerButton));
await tapEmoji(icon);
if (icon.type == FlowyIconType.emoji) {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
}
await pumpAndSettle();
}
@@ -841,6 +849,55 @@ extension CommonOperations on WidgetTester {
await tapButton(toggleHeading1);
await pumpUntilNotFound(addMenuItem);
}
/// Click the column menu button in the simple table
Future<void> clickColumnMenuButton(int index) async {
final columnMenuButton = find.byWidgetPredicate(
(w) =>
w is SimpleTableMobileReorderButton &&
w.index == index &&
w.type == SimpleTableMoreActionType.column,
);
await tapButton(columnMenuButton);
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
}
/// Click the row menu button in the simple table
Future<void> clickRowMenuButton(int index) async {
final rowMenuButton = find.byWidgetPredicate(
(w) =>
w is SimpleTableMobileReorderButton &&
w.index == index &&
w.type == SimpleTableMoreActionType.row,
);
await tapButton(rowMenuButton);
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
}
/// Click the SimpleTableQuickAction
Future<void> clickSimpleTableQuickAction(SimpleTableMoreAction action) async {
final button = find.byWidgetPredicate(
(widget) => widget is SimpleTableQuickAction && widget.type == action,
);
await tapButton(button);
}
/// Click the SimpleTableContentAction
Future<void> clickSimpleTableBoldContentAction() async {
final button = find.byType(SimpleTableContentBoldAction);
await tapButton(button);
}
/// Cancel the table action menu
Future<void> cancelTableActionMenu() async {
final finder = find.byType(SimpleTableCellBottomSheet);
if (finder.evaluate().isEmpty) {
return;
}
await tapAt(Offset.zero);
await pumpUntilNotFound(finder);
}
}
extension SettingsFinder on CommonFinders {
@@ -567,6 +567,12 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(phantom is PhantomChecklistItem, true);
}
void assertPhantomChecklistItemContent(String content) {
final phantom = find.byType(PhantomChecklistItem);
final text = find.text(content);
expect(find.descendant(of: phantom, matching: text), findsOneWidget);
}
Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid();
@@ -1,3 +1,12 @@
import 'dart:convert';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -11,4 +20,46 @@ extension EmojiTestExtension on WidgetTester {
);
await tapButton(emojiWidget);
}
Future<void> tapIcon(EmojiIconData icon) async {
final iconsData = IconsData.fromJson(jsonDecode(icon.emoji));
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.icon.tr),
);
expect(iconTab, findsOneWidget);
expect(find.byType(FlowyIconPicker), findsNothing);
await tap(iconTab);
await pumpAndSettle();
expect(find.byType(FlowyIconPicker), findsOneWidget);
final selectedSvg = find.descendant(
of: find.byType(FlowyIconPicker),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(find.byType(IconColorPicker), findsNothing);
await tapButton(selectedSvg);
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) {
return true;
}
}
}
return false;
}),
);
await tapButton(selectedColor);
}
}
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
@@ -5,6 +7,8 @@ import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
@@ -14,6 +18,7 @@ import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -117,7 +122,7 @@ extension Expectation on WidgetTester {
return;
}
final iconWidget = find.byWidgetPredicate(
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
(widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji,
);
expect(iconWidget, findsOneWidget);
}
@@ -223,24 +228,52 @@ extension Expectation on WidgetTester {
);
}
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) {
final pageName = findPageName(
name,
layout: layout,
);
final icon = find.descendant(
of: pageName,
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
final type = data.type;
if (type == FlowyIconType.emoji) {
final icon = find.descendant(
of: pageName,
matching: find.text(data.emoji),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.icon) {
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
final icon = find.descendant(
of: pageName,
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(icon, findsOneWidget);
}
}
void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
void expectViewTitleHasIcon(
String name,
ViewLayoutPB layout,
EmojiIconData data,
) {
final type = data.type;
if (type == FlowyIconType.emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(data.emoji),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.icon) {
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(icon, findsOneWidget);
}
}
void expectSelectedReminder(ReminderOption option) {
@@ -88,6 +88,8 @@ PODS:
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -112,6 +114,7 @@ DEPENDENCIES:
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
trunk:
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
@@ -199,6 +204,7 @@ SPEC CHECKSUMS:
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -8,5 +8,12 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:appflowy.com</string>
<string>applinks:appflowy.io</string>
<string>applinks:test.appflowy.com</string>
<string>applinks:test.appflowy.io</string>
</array>
</dict>
</plist>
@@ -58,6 +58,7 @@ class KVKeys {
static const String kCloudType = 'kCloudType';
static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL';
static const String kAppFlowyEnableSyncTrace = 'kAppFlowyEnableSyncTrace';
/// The key for saving the text scale factor.
///
@@ -108,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';
}
@@ -1,16 +1,24 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:open_filex/open_filex.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;
typedef OnFailureCallback = void Function(Uri uri);
Future<bool> afLaunchUrl(
/// Launch the uri
///
/// If the uri is a local file path, it will be opened with the OpenFilex.
/// Otherwise, it will be launched with the url_launcher.
Future<bool> afLaunchUri(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
@@ -18,6 +26,18 @@ Future<bool> afLaunchUrl(
String? webOnlyWindowName,
bool addingHttpSchemeWhenFailed = false,
}) async {
final url = uri.toString();
final decodedUrl = Uri.decodeComponent(url);
// check if the uri is the local file path
if (localPathRegex.hasMatch(decodedUrl)) {
return _afLaunchLocalUri(
uri,
context: context,
onFailure: onFailure,
);
}
// try to launch the uri directly
bool result;
try {
@@ -32,7 +52,7 @@ Future<bool> afLaunchUrl(
}
// if the uri is not a valid url, try to launch it with http scheme
final url = uri.toString();
if (addingHttpSchemeWhenFailed &&
!result &&
!isURL(url, {'require_protocol': true})) {
@@ -54,9 +74,14 @@ Future<bool> afLaunchUrl(
return result;
}
/// Launch the url string
///
/// See [afLaunchUri] for more details.
Future<bool> afLaunchUrlString(
String url, {
bool addingHttpSchemeWhenFailed = false,
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final Uri uri;
try {
@@ -67,12 +92,56 @@ Future<bool> afLaunchUrlString(
}
// try to launch the uri directly
return afLaunchUrl(
return afLaunchUri(
uri,
addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed,
context: context,
onFailure: onFailure,
);
}
/// Launch the local uri
///
/// See [afLaunchUri] for more details.
Future<bool> _afLaunchLocalUri(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final decodedUrl = Uri.decodeComponent(uri.toString());
// open the file with the OpenfileX
var result = await OpenFilex.open(decodedUrl);
if (result.type != ResultType.done) {
// For the file cant be opened, fallback to open the folder
final parentFolder = Directory(decodedUrl).parent.path;
result = await OpenFilex.open(parentFolder);
}
// show the toast if the file is not found
final message = switch (result.type) {
ResultType.done => LocaleKeys.openFileMessage_success.tr(),
ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(),
ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(),
ResultType.permissionDenied =>
LocaleKeys.openFileMessage_permissionDenied.tr(),
ResultType.error => LocaleKeys.failedToOpenUrl.tr(),
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
: ToastificationType.error,
);
}
final openFileSuccess = result.type == ResultType.done;
if (!openFileSuccess && onFailure != null) {
onFailure(uri);
Log.error('Failed to open file: $result.message');
}
return openFileSuccess;
}
void _errorHandler(
Uri uri, {
BuildContext? context,
+3
View File
@@ -39,6 +39,7 @@ class AppFlowyCloudConfiguration {
required this.base_url,
required this.ws_base_url,
required this.gotrue_url,
required this.enable_sync_trace,
});
factory AppFlowyCloudConfiguration.fromJson(Map<String, dynamic> json) =>
@@ -47,6 +48,7 @@ class AppFlowyCloudConfiguration {
final String base_url;
final String ws_base_url;
final String gotrue_url;
final bool enable_sync_trace;
Map<String, dynamic> toJson() => _$AppFlowyCloudConfigurationToJson(this);
@@ -55,6 +57,7 @@ class AppFlowyCloudConfiguration {
base_url: '',
ws_base_url: '',
gotrue_url: '',
enable_sync_trace: false,
);
}
+21
View File
@@ -212,6 +212,7 @@ class AppFlowyCloudSharedEnv {
base_url: Env.afCloudUrl,
ws_base_url: await _getAppFlowyCloudWSUrl(Env.afCloudUrl),
gotrue_url: await _getAppFlowyCloudGotrueUrl(Env.afCloudUrl),
enable_sync_trace: false,
);
return AppFlowyCloudSharedEnv(
@@ -241,12 +242,14 @@ Future<AppFlowyCloudConfiguration> configurationFromUri(
base_url: "$baseUrl:8000",
ws_base_url: "ws://${baseUri.host}:8000/ws/v1",
gotrue_url: "$baseUrl:9999",
enable_sync_trace: true,
);
} else {
return AppFlowyCloudConfiguration(
base_url: baseUrl,
ws_base_url: await _getAppFlowyCloudWSUrl(baseUrl),
gotrue_url: await _getAppFlowyCloudGotrueUrl(baseUrl),
enable_sync_trace: await getSyncLogEnabled(),
);
}
}
@@ -271,6 +274,24 @@ Future<String> getAppFlowyCloudUrl() async {
return result ?? kAppflowyCloudUrl;
}
Future<bool> getSyncLogEnabled() async {
final result =
await getIt<KeyValueStorage>().get(KVKeys.kAppFlowyEnableSyncTrace);
if (result == null) {
return false;
}
return result.toLowerCase() == "true";
}
Future<void> setSyncLogEnabled(bool enable) async {
await getIt<KeyValueStorage>().set(
KVKeys.kAppFlowyEnableSyncTrace,
enable.toString().toLowerCase(),
);
}
Future<String> _getAppFlowyCloudWSUrl(String baseURL) async {
try {
final uri = Uri.parse(baseURL);
@@ -6,7 +6,6 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -26,11 +25,13 @@ class DocumentPageStyleBloc
if (view.id.isEmpty) {
return;
}
final layoutObject =
await ViewBackendService.getView(view.id).fold(
(s) => jsonDecode(s.extra),
(f) => {},
);
Map layoutObject = {};
final data = await ViewBackendService.getView(view.id);
data.onSuccess((s) {
if (s.extra.isNotEmpty) {
layoutObject = jsonDecode(s.extra);
}
});
final fontLayout = _getSelectedFontLayout(layoutObject);
final lineHeightLayout = _getSelectedLineHeightLayout(
layoutObject,
@@ -7,6 +7,8 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
part 'recent_view_bloc.freezed.dart';
class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
@@ -39,7 +41,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
add(
RecentViewEvent.updateNameOrIcon(
view.name,
view.icon.value,
view.icon.toEmojiIconData(),
),
);
@@ -61,7 +63,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
emit(
state.copyWith(
name: view.name,
icon: view.icon.value,
icon: view.icon.toEmojiIconData(),
),
);
}
@@ -72,7 +74,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
emit(
state.copyWith(
name: view.name,
icon: view.icon.value,
icon: view.icon.toEmojiIconData(),
coverTypeV2: cover.type,
coverValue: cover.value,
),
@@ -82,7 +84,7 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
emit(
state.copyWith(
name: view.name,
icon: view.icon.value,
icon: view.icon.toEmojiIconData(),
coverTypeV1: coverTypeV1,
coverValue: coverValue,
),
@@ -135,14 +137,17 @@ class RecentViewBloc extends Bloc<RecentViewEvent, RecentViewState> {
@freezed
class RecentViewEvent with _$RecentViewEvent {
const factory RecentViewEvent.initial() = Initial;
const factory RecentViewEvent.updateCover(
CoverType coverTypeV1, // for the version under 0.5.5, including 0.5.5
CoverType coverTypeV1,
// for the version under 0.5.5, including 0.5.5
PageStyleCoverImageType? coverTypeV2, // for the version above 0.5.5
String? coverValue,
) = UpdateCover;
const factory RecentViewEvent.updateNameOrIcon(
String name,
String icon,
EmojiIconData icon,
) = UpdateNameOrIcon;
}
@@ -150,12 +155,12 @@ class RecentViewEvent with _$RecentViewEvent {
class RecentViewState with _$RecentViewState {
const factory RecentViewState({
required String name,
required String icon,
required EmojiIconData icon,
@Default(CoverType.none) CoverType coverTypeV1,
PageStyleCoverImageType? coverTypeV2,
@Default(null) String? coverValue,
}) = _RecentViewState;
factory RecentViewState.initial() =>
const RecentViewState(name: '', icon: '');
RecentViewState(name: '', icon: EmojiIconData.none());
}
@@ -7,11 +7,14 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -105,6 +108,12 @@ class _MobileViewPageState extends State<MobileViewPage> {
create: (_) =>
ShareBloc(view: view)..add(const ShareEvent.initial()),
),
if (state.userProfilePB != null)
BlocProvider(
create: (_) =>
UserWorkspaceBloc(userProfile: state.userProfilePB!)
..add(const UserWorkspaceEvent.initial()),
),
if (view.layout.isDocumentView)
BlocProvider(
create: (_) => DocumentPageStyleBloc(view: view)
@@ -255,15 +264,14 @@ class _MobileViewPageState extends State<MobileViewPage> {
}
Widget _buildTitle(BuildContext context, ViewPB? view) {
final icon = view?.icon.value;
final icon = view?.icon;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null && icon.isNotEmpty) ...[
FlowyText.emoji(
icon,
fontSize: 15.0,
figmaLineHeight: 18.0,
if (icon != null) ...[
EmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
),
const HSpace(4),
],
@@ -189,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
final url = context.read<ShareBloc>().state.url;
if (url.isNotEmpty) {
unawaited(
afLaunchUrl(
afLaunchUri(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
@@ -98,7 +98,7 @@ class BottomSheetBackButton extends StatelessWidget {
width: 18,
height: 18,
child: FlowySvg(
FlowySvgs.m_app_bar_back_s,
FlowySvgs.m_bottom_sheet_back_s,
),
),
),
@@ -138,7 +138,6 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
@@ -191,6 +190,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.unpublish,
),
),
_divider(),
];
} else {
return [
@@ -201,6 +201,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.publish,
),
),
_divider(),
];
}
}
@@ -211,14 +211,21 @@ class BottomSheetHeader extends StatelessWidget {
required this.showDoneButton,
this.onRemove,
this.onDone,
this.onBack,
this.onClose,
});
final String title;
final bool showBackButton;
final bool showCloseButton;
final bool showRemoveButton;
final String title;
final bool showDoneButton;
final VoidCallback? onRemove;
final VoidCallback? onBack;
final VoidCallback? onClose;
final void Function(BuildContext context)? onDone;
@override
@@ -230,14 +237,18 @@ class BottomSheetHeader extends StatelessWidget {
child: Stack(
children: [
if (showBackButton)
const Align(
Align(
alignment: Alignment.centerLeft,
child: BottomSheetBackButton(),
child: BottomSheetBackButton(
onTap: onBack,
),
),
if (showCloseButton)
const Align(
Align(
alignment: Alignment.centerLeft,
child: BottomSheetCloseButton(),
child: BottomSheetCloseButton(
onTap: onClose,
),
),
if (showRemoveButton)
Align(
@@ -228,7 +228,7 @@ class _UserIcon extends StatelessWidget {
fontSize: 26,
),
onTap: () async {
final icon = await context.push<EmojiPickerResult>(
final icon = await context.push<EmojiIconData>(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
@@ -111,7 +111,7 @@ class MobileRecentView extends StatelessWidget {
padding: const EdgeInsets.only(left: 8.0),
child: state.icon.isNotEmpty
? EmojiText(
emoji: state.icon,
emoji: state.icon.emoji,
fontSize: 30.0,
)
: SizedBox.square(
@@ -7,6 +7,7 @@ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.
import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
@@ -26,7 +27,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import 'package:time/time.dart';
@@ -118,7 +118,7 @@ class MobileViewPage extends StatelessWidget {
}
Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) {
final supportAvatar = isURL(state.icon);
final supportAvatar = isURL(state.icon.emoji);
if (!supportAvatar) {
return _buildLastViewed(context);
}
@@ -173,21 +173,19 @@ class MobileViewPage extends StatelessWidget {
Widget _buildTitle(BuildContext context, RecentViewState state) {
final name = state.name;
final icon = state.icon;
final fontFamily = Platform.isAndroid || Platform.isLinux
? GoogleFonts.notoColorEmoji().fontFamily
: null;
return RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: icon,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 17.0,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
),
WidgetSpan(
child: SizedBox(
width: 20,
child: EmojiIconWidget(
emoji: icon,
emojiSize: 17.0,
),
),
),
if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
TextSpan(
@@ -14,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
const _kNotificationIconHeight = 36.0;
@@ -264,7 +265,11 @@ class NotificationDocumentContent extends StatelessWidget {
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
customHeadingPadding: EdgeInsets.zero,
customHeadingPadding: UniversalPlatform.isDesktop
? EdgeInsets.zero
: EdgeInsets.symmetric(
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
),
);
return IgnorePointer(
@@ -2,6 +2,8 @@ import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -121,6 +123,7 @@ class InnerMobileViewItem extends StatelessWidget {
final bool isDraggable;
final bool isExpanded;
final bool isFirstChild;
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
@@ -230,6 +233,7 @@ class SingleMobileInnerViewItem extends StatefulWidget {
final ViewPB view;
final ViewPB? parentView;
final bool isExpanded;
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
@@ -297,11 +301,9 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
Widget _buildViewIcon() {
final icon = widget.view.icon.value.isNotEmpty
? FlowyText.emoji(
widget.view.icon.value,
fontSize: Platform.isAndroid ? 16.0 : 18.0,
figmaLineHeight: 20.0,
optimizeEmojiAlign: true,
? EmojiIconWidget(
emoji: widget.view.icon.toEmojiIconData(),
emojiSize: Platform.isAndroid ? 16.0 : 18.0,
)
: Opacity(
opacity: 0.7,
@@ -34,7 +34,7 @@ class MobileQuickActionButton extends StatelessWidget {
enable ? null : const WidgetStatePropertyAll(Colors.transparent),
splashColor: Colors.transparent,
child: Container(
height: 52,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
@@ -5,6 +5,7 @@ import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.da
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-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -20,18 +21,6 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
_init();
}
ChatInputFileMetadata consumeMetadata() {
final metadata = {
for (final file in state.uploadFiles) file.filePath: file,
};
if (metadata.isNotEmpty) {
add(const AIPromptInputEvent.clear());
}
return metadata;
}
final LocalLLMListener _listener;
@override
@@ -44,15 +33,6 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
newFile: (String filePath, String fileName) {
final files = [...state.uploadFiles];
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
files.add(newFile);
emit(state.copyWith(uploadFiles: files));
}
},
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
@@ -80,19 +60,37 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
),
);
},
deleteFile: (file) {
final files = List<ChatFile>.from(state.uploadFiles);
attachFile: (filePath, fileName) {
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
emit(
state.copyWith(
attachedFiles: [...state.attachedFiles, newFile],
),
);
}
},
removeFile: (file) {
final files = [...state.attachedFiles];
files.remove(file);
emit(
state.copyWith(
uploadFiles: files,
attachedFiles: files,
),
);
},
clear: () {
updateMentionedViews: (views) {
emit(
state.copyWith(
uploadFiles: [],
mentionedPages: views,
),
);
},
clearMetadata: () {
emit(
state.copyWith(
attachedFiles: [],
mentionedPages: [],
),
);
},
@@ -126,35 +124,55 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
Log.error,
);
}
Map<String, dynamic> consumeMetadata() {
final metadata = {
for (final file in state.attachedFiles) file.filePath: file,
for (final page in state.mentionedPages) page.id: page,
};
if (metadata.isNotEmpty && !isClosed) {
add(const AIPromptInputEvent.clearMetadata());
}
return metadata;
}
}
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory AIPromptInputEvent.deleteFile(ChatFile file) = _DeleteFile;
const factory AIPromptInputEvent.clear() = _ClearFile;
const factory AIPromptInputEvent.updateChatState(
LocalAIChatPB chatState,
) = _UpdateChatState;
const factory AIPromptInputEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory AIPromptInputEvent.attachFile(
String filePath,
String fileName,
) = _AttachFile;
const factory AIPromptInputEvent.removeFile(ChatFile file) = _RemoveFile;
const factory AIPromptInputEvent.updateMentionedViews(List<ViewPB> views) =
_UpdateMentionedViews;
const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata;
}
@freezed
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required bool supportChatWithFile,
LocalAIChatPB? chatState,
required List<ChatFile> uploadFiles,
required AIType aiType,
required bool supportChatWithFile,
required LocalAIChatPB? chatState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
}) = _AIPromptInputState;
factory AIPromptInputState.initial() => const AIPromptInputState(
supportChatWithFile: false,
uploadFiles: [],
aiType: AIType.appflowyAI,
supportChatWithFile: false,
chatState: null,
attachedFiles: [],
mentionedPages: [],
);
}
@@ -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;
@@ -63,6 +65,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Future<void> close() async {
await answerStream?.dispose();
await listener.stop();
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
return super.close();
}
@@ -76,10 +81,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await chatController.insert(message, index: 0);
}
if (state.loadingState.isLoading) {
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: () {
@@ -151,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;
@@ -172,11 +179,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
finishSending: (ChatMessagePB message) {
lastSentMessage = message;
finishSending: () {
emit(
state.copyWith(
promptResponseState: PromptResponseState.awaitingAnswer,
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
@@ -214,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) {
@@ -233,6 +231,46 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
regenerateAnswer: (id) {
_clearRelatedQuestions();
_regenerateAnswer(id);
lastSentMessage = null;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
didReceiveChatSettings: (settings) {
emit(
state.copyWith(
selectedSourceIds: settings.ragIds.ragIds,
onlyUseSelectedSources: settings.ragOnly,
),
);
},
updateSelectedSources: (selectedSourcesIds) async {
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
final ragIds = RepeatedRagId(ragIds: selectedSourcesIds);
final payload = UpdateChatSettingsPB.create()
..chatId = ChatId(value: chatId)
..ragIds = ragIds;
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
setRagOnly: (ragOnly) async {
emit(state.copyWith(onlyUseSelectedSources: ragOnly));
final payload = UpdateChatSettingsPB.create()
..chatId = ChatId(value: chatId)
..ragOnly = ragOnly;
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
);
},
);
@@ -318,12 +356,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _init() async {
final payload = LoadNextChatMessagePB(
void _loadSetting() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().fold(
(settings) {
if (!isClosed) {
add(ChatEvent.didReceiveChatSettings(settings: settings));
}
},
Log.error,
);
}
void _loadMessages() async {
final loadMessagesPayload = LoadNextChatMessagePB(
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(payload).send().fold(
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
@@ -388,8 +439,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
question.messageId,
);
add(ChatEvent.finishSending(question));
add(ChatEvent.startAnswerStreaming(streamAnswer));
lastSentMessage = question;
add(const ChatEvent.finishSending());
add(ChatEvent.receiveMessage(streamAnswer));
}
},
(err) {
@@ -416,6 +468,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _regenerateAnswer(String answerMessageIdString) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
answerMessageIdString;
final answerMessageId = Int64.tryParseInt(id);
if (answerMessageId == null) {
return;
}
await answerStream?.dispose();
answerStream = AnswerStream();
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
await AIEventRegenerateResponse(payload).send().fold(
(success) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
answerStream!,
answerMessageId - 1,
).copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
);
}
Message _createAnswerStreamMessage(
AnswerStream stream,
Int64 questionMessageId,
@@ -474,22 +562,48 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
);
}
void _clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
}
@freezed
class ChatEvent with _$ChatEvent {
// chat settings
const factory ChatEvent.didReceiveChatSettings({
required ChatSettingsPB settings,
}) = _DidReceiveChatSettings;
const factory ChatEvent.updateSelectedSources({
required List<String> selectedSourcesIds,
}) = _UpdateSelectedSources;
const factory ChatEvent.setRagOnly({
required bool ragOnly,
}) = _SetRagOnly;
// send message
const factory ChatEvent.sendMessage({
required String message,
Map<String, dynamic>? metadata,
}) = _SendMessage;
const factory ChatEvent.finishSending(ChatMessagePB message) =
_FinishSendMessage;
const factory ChatEvent.finishSending() = _FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// regenerate
const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer;
// streaming answer
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.stopStream() = _StopStream;
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
@@ -514,12 +628,16 @@ class ChatEvent with _$ChatEvent {
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required ChatLoadingState loadingState,
required bool onlyUseSelectedSources,
required List<String> selectedSourceIds,
required LoadChatMessageStatus loadingState,
required PromptResponseState promptResponseState,
}) = _ChatState;
factory ChatState.initial() => const ChatState(
loadingState: ChatLoadingState.loading(),
onlyUseSelectedSources: false,
selectedSourceIds: [],
loadingState: LoadChatMessageStatus.loading,
promptResponseState: PromptResponseState.ready,
);
}
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatEditDocumentService {
static Future<ViewPB?> saveMessagesToNewPage(
String chatPageName,
String parentViewId,
List<TextMessage> messages,
) async {
if (messages.isEmpty) {
return null;
}
// Convert messages to markdown and trim the last empty newline.
final completeMessage = messages.map((m) => m.text).join('\n').trimRight();
if (completeMessage.isEmpty) {
return null;
}
final document = customMarkdownToDocument(completeMessage);
final initialBytes =
DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();
if (initialBytes == null) {
Log.error('Failed to convert messages to document');
return null;
}
return ViewBackendService.createView(
name: LocaleKeys.chat_addToNewPageName.tr(args: [chatPageName]),
layoutType: ViewLayoutPB.Document,
parentViewId: parentViewId,
initialDataBytes: initialBytes,
).toNullable();
}
static Future<void> addMessageToPage(
String documentId,
TextMessage message,
) async {
if (message.text.isEmpty) {
Log.error('Message is empty');
return;
}
final bloc = DocumentBloc(
documentId: documentId,
saveToBlocMap: false,
)..add(const DocumentEvent.initial());
if (bloc.state.editorState == null) {
await bloc.stream.firstWhere((state) => state.editorState != null);
}
final editorState = bloc.state.editorState;
if (editorState == null) {
Log.error("Can't get EditorState of document");
return;
}
final messageDocument = customMarkdownToDocument(message.text);
if (messageDocument.isEmpty) {
Log.error('Failed to convert message to document');
return;
}
final lastNodeOrNull = editorState.document.root.children.lastOrNull;
final rootIsEmpty = lastNodeOrNull == null;
final isLastLineEmpty = lastNodeOrNull?.children.isNotEmpty == false &&
lastNodeOrNull?.delta?.isNotEmpty == false;
final nodes = [
if (rootIsEmpty || !isLastLineEmpty) paragraphNode(),
...messageDocument.root.children,
];
final insertPath = rootIsEmpty || listEquals(lastNodeOrNull.path, const [0])
? const [0]
: lastNodeOrNull.path.next;
final transaction = editorState.transaction..insertNodes(insertPath, nodes);
await editorState.apply(transaction);
await bloc.close();
}
}
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
@@ -56,7 +57,6 @@ class AIChatProgress {
enum PromptResponseState {
ready,
sendingQuestion,
awaitingAnswer,
streamingAnswer,
}
@@ -76,19 +76,19 @@ class ChatFile extends Equatable {
final fileName = path.basename(filePath);
final extension = path.extension(filePath).toLowerCase();
ChatMessageMetaTypePB fileType;
ContextLoaderTypePB fileType;
switch (extension) {
case '.pdf':
fileType = ChatMessageMetaTypePB.PDF;
fileType = ContextLoaderTypePB.PDF;
break;
case '.txt':
fileType = ChatMessageMetaTypePB.Txt;
fileType = ContextLoaderTypePB.Txt;
break;
case '.md':
fileType = ChatMessageMetaTypePB.Markdown;
fileType = ContextLoaderTypePB.Markdown;
break;
default:
fileType = ChatMessageMetaTypePB.UnknownMetaType;
fileType = ContextLoaderTypePB.UnknownLoaderType;
}
return ChatFile(
@@ -100,13 +100,14 @@ class ChatFile extends Equatable {
final String filePath;
final String fileName;
final ChatMessageMetaTypePB fileType;
final ContextLoaderTypePB fileType;
@override
List<Object?> get props => [filePath];
}
typedef ChatInputFileMetadata = Map<String, ChatFile>;
typedef ChatFileMap = Map<String, ChatFile>;
typedef ChatMentionedPageMap = Map<String, ViewPB>;
@freezed
class ChatLoadingState with _$ChatLoadingState {
@@ -130,3 +131,9 @@ const onetimeShotType = "OnetimeShotType";
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
return metadata?[onetimeShotType];
}
enum LoadChatMessageStatus {
loading,
loadingRemote,
ready,
}
@@ -1,217 +0,0 @@
import 'dart:async';
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:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_input_action_control.dart';
part 'chat_input_action_bloc.freezed.dart';
class ChatInputActionBloc
extends Bloc<ChatInputActionEvent, ChatInputActionState> {
ChatInputActionBloc({required this.chatId})
: super(const ChatInputActionState()) {
on<ChatInputActionEvent>(_handleEvent);
}
final String chatId;
Future<void> _handleEvent(
ChatInputActionEvent event,
Emitter<ChatInputActionState> emit,
) async {
await event.when(
started: () async {
unawaited(
ViewBackendService.getAllViews().then(
(result) {
final views = result
.toNullable()
?.items
.where(
(v) =>
v.layout.isDocumentView &&
!v.isSpace &&
v.parentViewId.isNotEmpty,
)
.toList() ??
[];
if (!isClosed) {
add(ChatInputActionEvent.refreshViews(views));
}
},
),
);
},
refreshViews: (List<ViewPB> views) {
final List<ViewActionPage> pages = _filterPages(
views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
views: views,
pages: pages,
indicator: const ChatActionMenuIndicator.ready(),
),
);
},
filter: (String filter) {
Log.debug("Filter chat input pages: $filter");
final List<ViewActionPage> pages = _filterPages(
state.views,
state.selectedPages,
filter,
);
emit(state.copyWith(pages: pages, filter: filter));
},
handleKeyEvent: (PhysicalKeyboardKey physicalKey) {
emit(
state.copyWith(
keyboardKey: ChatInputKeyboardEvent(physicalKey: physicalKey),
),
);
},
addPage: (ChatInputMention page) {
if (!state.selectedPages.any((p) => p.pageId == page.pageId)) {
final List<ViewActionPage> pages = _filterPages(
state.views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
pages: pages,
selectedPages: [...state.selectedPages, page],
),
);
}
},
removePage: (String text) {
final List<ChatInputMention> selectedPages =
List.from(state.selectedPages);
selectedPages.retainWhere((t) => !text.contains(t.title));
final List<ViewActionPage> allPages = _filterPages(
state.views,
state.selectedPages,
state.filter,
);
emit(
state.copyWith(
selectedPages: selectedPages,
pages: allPages,
),
);
},
clear: () {
emit(
state.copyWith(
selectedPages: [],
filter: "",
),
);
},
);
}
}
List<ViewActionPage> _filterPages(
List<ViewPB> views,
List<ChatInputMention> selectedPages,
String filter,
) {
final pages = views
.map(
(v) => ViewActionPage(view: v),
)
.toList();
pages.retainWhere((page) {
return !selectedPages.contains(page);
});
if (filter.isEmpty) {
return pages;
}
return pages
.where(
(v) => v.title.toLowerCase().contains(filter.toLowerCase()),
)
.toList();
}
class ViewActionPage extends ChatInputMention {
ViewActionPage({required this.view});
final ViewPB view;
@override
String get pageId => view.id;
@override
String get title => view.name;
@override
List<Object?> get props => [pageId];
@override
dynamic get page => view;
@override
Widget get icon => view.defaultIcon();
}
@freezed
class ChatInputActionEvent with _$ChatInputActionEvent {
const factory ChatInputActionEvent.started() = _Started;
const factory ChatInputActionEvent.refreshViews(List<ViewPB> views) =
_RefreshViews;
const factory ChatInputActionEvent.filter(String filter) = _Filter;
const factory ChatInputActionEvent.handleKeyEvent(
PhysicalKeyboardKey keyboardKey,
) = _HandleKeyEvent;
const factory ChatInputActionEvent.addPage(ChatInputMention page) = _AddPage;
const factory ChatInputActionEvent.removePage(String text) = _RemovePage;
const factory ChatInputActionEvent.clear() = _Clear;
}
@freezed
class ChatInputActionState with _$ChatInputActionState {
const factory ChatInputActionState({
@Default([]) List<ViewPB> views,
@Default([]) List<ChatInputMention> pages,
@Default([]) List<ChatInputMention> selectedPages,
@Default("") String filter,
ChatInputKeyboardEvent? keyboardKey,
@Default(ChatActionMenuIndicator.loading())
ChatActionMenuIndicator indicator,
}) = _ChatInputActionState;
}
class ChatInputKeyboardEvent extends Equatable {
ChatInputKeyboardEvent({required this.physicalKey});
final PhysicalKeyboardKey physicalKey;
final int timestamp = DateTime.now().millisecondsSinceEpoch;
@override
List<Object?> get props => [timestamp];
}
@freezed
class ChatActionMenuIndicator with _$ChatActionMenuIndicator {
const factory ChatActionMenuIndicator.ready() = _Ready;
const factory ChatActionMenuIndicator.loading() = _Loading;
}
@@ -1,172 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
import 'package:appflowy_backend/log.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
abstract class ChatInputMention extends Equatable {
String get title;
String get pageId;
dynamic get page;
Widget get icon;
}
/// Key: the key is the pageId
typedef ChatInputMentionMetadata = Map<String, ChatInputMention>;
class ChatInputActionControl extends ChatActionHandler {
ChatInputActionControl({
required this.textController,
required this.textFieldFocusNode,
required this.chatId,
}) : _commandBloc = ChatInputActionBloc(chatId: chatId);
final TextEditingController textController;
final ChatInputActionBloc _commandBloc;
final FocusNode textFieldFocusNode;
final String chatId;
// Private attributes
String _atText = "";
String _prevText = "";
String _showMenuText = "";
// Getter
List<String> get tags =>
_commandBloc.state.selectedPages.map((e) => e.title).toList();
ChatInputMentionMetadata consumeMetaData() {
final metadata = _commandBloc.state.selectedPages.fold(
<String, ChatInputMention>{},
(map, page) => map..putIfAbsent(page.pageId, () => page),
);
if (metadata.isNotEmpty) {
_commandBloc.add(const ChatInputActionEvent.clear());
}
return metadata;
}
void handleKeyEvent(KeyEvent event) {
// ignore: deprecated_member_use
if (event is KeyDownEvent || event is RawKeyDownEvent) {
_commandBloc.add(ChatInputActionEvent.handleKeyEvent(event.physicalKey));
}
}
bool canHandleKeyEvent(KeyEvent event) {
return _showMenuText.isNotEmpty &&
<PhysicalKeyboardKey>{
PhysicalKeyboardKey.arrowDown,
PhysicalKeyboardKey.arrowUp,
PhysicalKeyboardKey.enter,
PhysicalKeyboardKey.escape,
}.contains(event.physicalKey);
}
void dispose() {
_commandBloc.close();
}
@override
void onSelected(ChatInputMention page) {
_commandBloc.add(ChatInputActionEvent.addPage(page));
textController.text = "$_showMenuText${page.title}";
onExit();
}
@override
void onExit() {
_atText = "";
_showMenuText = "";
_prevText = "";
_commandBloc.add(const ChatInputActionEvent.filter(""));
}
@override
void onEnter() {
_commandBloc.add(const ChatInputActionEvent.started());
_showMenuText = textController.text;
}
@override
double actionMenuOffsetX() {
final TextPosition textPosition = textController.selection.extent;
if (textFieldFocusNode.context == null) {
return 0;
}
final RenderBox renderBox =
textFieldFocusNode.context?.findRenderObject() as RenderBox;
final TextPainter textPainter = TextPainter(
text: TextSpan(text: textController.text),
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: renderBox.size.width,
maxWidth: renderBox.size.width,
);
final Offset caretOffset =
textPainter.getOffsetForCaret(textPosition, Rect.zero);
final List<TextBox> boxes = textPainter.getBoxesForSelection(
TextSelection(
baseOffset: textPosition.offset,
extentOffset: textPosition.offset,
),
);
if (boxes.isNotEmpty) {
return boxes.last.right;
}
return caretOffset.dx;
}
bool onTextChanged(String text) {
final String inputText = text;
if (_prevText.length > inputText.length) {
final deleteStartIndex = textController.selection.baseOffset;
final deleteEndIndex =
_prevText.length - inputText.length + deleteStartIndex;
final deletedText = _prevText.substring(deleteStartIndex, deleteEndIndex);
_commandBloc.add(ChatInputActionEvent.removePage(deletedText));
}
// If the action menu is shown, filter the views
if (_showMenuText.isNotEmpty) {
if (text.length >= _showMenuText.length) {
final filterText = inputText.substring(_showMenuText.length);
_commandBloc.add(ChatInputActionEvent.filter(filterText));
}
// If the text change from "xxx @"" to "xxx", which means user delete the @, we should hide the action menu
if (_atText.isNotEmpty && !inputText.contains(_atText)) {
_commandBloc.add(
const ChatInputActionEvent.handleKeyEvent(PhysicalKeyboardKey.escape),
);
}
} else {
final isTypingNewAt =
text.endsWith("@") && _prevText.length < text.length;
if (isTypingNewAt) {
_atText = text;
_prevText = text;
return true;
}
}
_prevText = text;
return false;
}
@override
void onFilter(String filter) {
Log.info("filter: $filter");
}
@override
ChatInputActionBloc get commandBloc => _commandBloc;
}
@@ -0,0 +1,246 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.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:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_control_cubit.freezed.dart';
class ChatInputControlCubit extends Cubit<ChatInputControlState> {
ChatInputControlCubit() : super(const ChatInputControlState.loading());
final List<ViewPB> allViews = [];
final List<String> selectedViewIds = [];
/// used when mentioning a page
///
/// the text position after the @ character
int _filterStartPosition = -1;
/// used when mentioning a page
///
/// the text position after the @ character, at the end of the filter
int _filterEndPosition = -1;
/// used when mentioning a page
///
/// the entire string input in the prompt
String _inputText = "";
/// used when mentioning a page
///
/// the current filtering text, after the @ characater
String _filter = "";
String get inputText => _inputText;
int get filterStartPosition => _filterStartPosition;
int get filterEndPosition => _filterEndPosition;
void refreshViews() async {
final newViews = await ViewBackendService.getAllViews().fold(
(result) {
return result.items
.where(
(v) =>
!v.isSpace &&
v.layout.isDocumentView &&
v.parentViewId != v.id,
)
.toList();
},
(err) {
Log.error(err);
return <ViewPB>[];
},
);
allViews
..clear()
..addAll(newViews);
// update visible views
newViews.retainWhere((v) => !selectedViewIds.contains(v.id));
if (_filter.isNotEmpty) {
newViews.retainWhere(
(v) {
final nonEmptyName = v.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: v.name;
return nonEmptyName.toLowerCase().contains(_filter);
},
);
}
final focusedViewIndex = newViews.isEmpty ? -1 : 0;
emit(
ChatInputControlState.ready(
visibleViews: newViews,
focusedViewIndex: focusedViewIndex,
),
);
}
void startSearching(TextEditingValue textEditingValue) {
_filterStartPosition =
_filterEndPosition = textEditingValue.selection.baseOffset;
_filter = "";
_inputText = textEditingValue.text;
state.maybeMap(
ready: (readyState) {
emit(
readyState.copyWith(
visibleViews: allViews,
focusedViewIndex: allViews.isEmpty ? -1 : 0,
),
);
},
orElse: () {},
);
}
void reset() {
_filterStartPosition = _filterEndPosition = -1;
_filter = _inputText = "";
state.maybeMap(
ready: (readyState) {
emit(
readyState.copyWith(
visibleViews: allViews,
focusedViewIndex: allViews.isEmpty ? -1 : 0,
),
);
},
orElse: () {},
);
}
void updateFilter(
String newInputText,
String newFilter, {
int? newEndPosition,
}) {
updateInputText(newInputText);
// filter the views
_filter = newFilter.toLowerCase();
if (newEndPosition != null) {
_filterEndPosition = newEndPosition;
}
final newVisibleViews =
allViews.where((v) => !selectedViewIds.contains(v.id)).toList();
if (_filter.isNotEmpty) {
newVisibleViews.retainWhere(
(v) {
final nonEmptyName = v.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: v.name;
return nonEmptyName.toLowerCase().contains(_filter);
},
);
}
state.maybeWhen(
ready: (_, oldFocusedIndex) {
final newFocusedViewIndex = oldFocusedIndex < newVisibleViews.length
? oldFocusedIndex
: (newVisibleViews.isEmpty ? -1 : 0);
emit(
ChatInputControlState.ready(
visibleViews: newVisibleViews,
focusedViewIndex: newFocusedViewIndex,
),
);
},
orElse: () {},
);
}
void updateInputText(String newInputText) {
_inputText = newInputText;
// input text is changed, see if there are any deletions
selectedViewIds.retainWhere(_inputText.contains);
_notifyUpdateSelectedViews();
}
void updateSelectionUp() {
state.maybeMap(
ready: (readyState) {
final newIndex = readyState.visibleViews.isEmpty
? -1
: (readyState.focusedViewIndex - 1) %
readyState.visibleViews.length;
emit(
readyState.copyWith(focusedViewIndex: newIndex),
);
},
orElse: () {},
);
}
void updateSelectionDown() {
state.maybeMap(
ready: (readyState) {
final newIndex = readyState.visibleViews.isEmpty
? -1
: (readyState.focusedViewIndex + 1) %
readyState.visibleViews.length;
emit(
readyState.copyWith(focusedViewIndex: newIndex),
);
},
orElse: () {},
);
}
void selectPage(ViewPB view) {
selectedViewIds.add(view.id);
_notifyUpdateSelectedViews();
reset();
}
String formatIntputText(final String input) {
String result = input;
for (final viewId in selectedViewIds) {
if (!result.contains(viewId)) {
continue;
}
final view = allViews.firstWhereOrNull((view) => view.id == viewId);
if (view != null) {
final nonEmptyName = view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name;
result = result.replaceAll(RegExp(viewId), nonEmptyName);
}
}
return result;
}
void _notifyUpdateSelectedViews() {
final stateCopy = state;
final selectedViews =
allViews.where((view) => selectedViewIds.contains(view.id)).toList();
emit(ChatInputControlState.updateSelectedViews(selectedViews));
emit(stateCopy);
}
}
@freezed
class ChatInputControlState with _$ChatInputControlState {
const factory ChatInputControlState.loading() = _Loading;
const factory ChatInputControlState.ready({
required List<ViewPB> visibleViews,
required int focusedViewIndex,
}) = _Ready;
const factory ChatInputControlState.updateSelectedViews(
List<ViewPB> selectedViews,
) = _UpdateOneShot;
}
@@ -1,12 +1,12 @@
import 'dart:convert';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
@@ -129,16 +129,16 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
for (final value in map.values) {
switch (value) {
case ViewActionPage(view: final view) when view.layout.isDocumentView:
final payload = OpenDocumentPayloadPB(documentId: view.id);
case ViewPB _ when value.layout.isDocumentView:
final payload = OpenDocumentPayloadPB(documentId: value.id);
await DocumentEventGetDocumentText(payload).send().fold(
(pb) {
metadata.add(
ChatMessageMetaPB(
id: view.id,
name: view.name,
id: value.id,
name: value.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
loaderType: ContextLoaderTypePB.Txt,
source: appflowySource,
),
);
@@ -156,7 +156,7 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
id: nanoid(8),
name: fileName,
data: filePath,
dataType: fileType,
loaderType: fileType,
source: filePath,
),
);
@@ -0,0 +1,399 @@
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_select_sources_cubit.freezed.dart';
const int _kMaxSelectedParentPageCount = 3;
enum SourceSelectedStatus {
unselected,
selected,
partiallySelected;
bool get isUnselected => this == unselected;
bool get isSelected => this == selected;
bool get isPartiallySelected => this == partiallySelected;
}
class ChatSource {
ChatSource({
required this.view,
required this.parentView,
required this.children,
required bool isExpanded,
required SourceSelectedStatus selectedStatus,
required IgnoreViewType ignoreStatus,
}) : isExpandedNotifier = ValueNotifier(isExpanded),
selectedStatusNotifier = ValueNotifier(selectedStatus),
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
final ViewPB view;
final ViewPB? parentView;
final List<ChatSource> children;
final ValueNotifier<bool> isExpandedNotifier;
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
bool get isExpanded => isExpandedNotifier.value;
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
void toggleIsExpanded() {
isExpandedNotifier.value = !isExpanded;
}
ChatSource copy() {
return ChatSource(
view: view,
parentView: parentView,
children: children.map<ChatSource>((child) => child.copy()).toList(),
ignoreStatus: ignoreStatus,
isExpanded: isExpanded,
selectedStatus: selectedStatus,
);
}
ChatSource? findChildBySourceId(String sourceId) {
if (view.id == sourceId) {
return this;
}
for (final child in children) {
final childResult = child.findChildBySourceId(sourceId);
if (childResult != null) {
return childResult;
}
}
return null;
}
void resetIgnoreViewTypeRecursive() {
ignoreStatusNotifier.value = view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable;
for (final child in children) {
child.resetIgnoreViewTypeRecursive();
}
}
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
ignoreStatusNotifier.value = newIgnoreViewType;
for (final child in children) {
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
}
}
void dispose() {
for (final child in children) {
child.dispose();
}
isExpandedNotifier.dispose();
selectedStatusNotifier.dispose();
ignoreStatusNotifier.dispose();
}
}
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
ChatSettingsCubit() : super(ChatSettingsState.initial());
List<String> selectedSourceIds = [];
ChatSource? source;
List<ChatSource> selectedSources = [];
String filter = '';
void updateOnlyUseSelectedSources(bool onlyUseSelectedSources) {
if (state.onlyUseSelectedSources != onlyUseSelectedSources) {
emit(state.copyWith(onlyUseSelectedSources: onlyUseSelectedSources));
}
}
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds = [...newSelectedSourceIds];
}
void refreshSources(ViewPB view) async {
filter = "";
final newSource = await _recursiveBuild(view, null);
_restrictSelectionIfNecessary(newSource.children);
final selected = _buildSelectedSources(newSource).toList();
newSource.toggleIsExpanded();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: [newSource],
),
);
source?.dispose();
source = newSource.copy();
selectedSources
..forEach((e) => e.dispose())
..clear()
..addAll(selected.map((e) => e.copy()));
}
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
final isThisSourceSelected = selectedSourceIds.contains(view.id);
final childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
int selectedCount = 0;
final children = <ChatSource>[];
if (childrenViews != null) {
for (final childView in childrenViews) {
if (childView.layout == ViewLayoutPB.Chat) {
continue;
}
final childChatSource = await _recursiveBuild(childView, view);
if (childChatSource.selectedStatus.isSelected) {
selectedCount++;
}
children.add(childChatSource);
}
final areAllChildrenSelectedOrNoChildren =
children.length == selectedCount;
final isAnyChildNotUnselected =
children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
}
} else if (isThisSourceSelected) {
selectedStatus = SourceSelectedStatus.selected;
}
return ChatSource(
view: view,
parentView: parentView,
children: children,
ignoreStatus: view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable,
isExpanded: false,
selectedStatus: selectedStatus,
);
}
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
for (final source in sources) {
source.resetIgnoreViewTypeRecursive();
}
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
_kMaxSelectedParentPageCount) {
sources
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
.forEach(
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
);
}
}
void updateFilter(String filter) {
this.filter = filter;
for (final source in state.visibleSources) {
source.dispose();
}
if (source == null) {
emit(ChatSettingsState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
final visible = [_buildSearchResults(source!)].nonNulls.toList();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: visible,
),
);
}
}
/// traverse tree to build up search query
ChatSource? _buildSearchResults(ChatSource chatSource) {
final isVisible = chatSource.view.nameOrDefault
.toLowerCase()
.contains(filter.toLowerCase());
final childrenResults = <ChatSource>[];
for (final childSource in chatSource.children) {
final childResult = _buildSearchResults(childSource);
if (childResult != null) {
childrenResults.add(childResult);
}
}
return isVisible || childrenResults.isNotEmpty
? ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
children: childrenResults,
ignoreStatus: chatSource.ignoreStatus,
isExpanded: chatSource.isExpanded,
selectedStatus: chatSource.selectedStatus,
)
: null;
}
/// traverse tree to build up selected sources
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
final children = <ChatSource>[];
for (final childSource in chatSource.children) {
children.addAll(_buildSelectedSources(childSource));
}
return selectedSourceIds.contains(chatSource.view.id)
? [
ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
children: children,
ignoreStatus: chatSource.ignoreStatus,
selectedStatus: chatSource.selectedStatus,
isExpanded: true,
),
]
: children;
}
void toggleSelectedStatus(ChatSource chatSource) {
if (chatSource.view.isSpace) {
return;
}
final allIds = _recursiveGetSourceIds(chatSource);
if (chatSource.selectedStatus.isUnselected ||
chatSource.selectedStatus.isPartiallySelected &&
!chatSource.view.layout.isDocumentView) {
for (final id in allIds) {
if (!selectedSourceIds.contains(id)) {
selectedSourceIds.add(id);
}
}
} else {
for (final id in allIds) {
if (selectedSourceIds.contains(id)) {
selectedSourceIds.remove(id);
}
}
}
updateSelectedStatus();
}
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
return [
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
for (final childSource in chatSource.children)
..._recursiveGetSourceIds(childSource),
];
}
void updateSelectedStatus() {
if (source == null) {
return;
}
_recursiveUpdateSelectedStatus(source!);
_restrictSelectionIfNecessary(source!.children);
for (final visibleSource in state.visibleSources) {
visibleSource.dispose();
}
final visible = [_buildSearchResults(source!)].nonNulls.toList();
selectedSources
..forEach((e) => e.dispose())
..clear()
..addAll(_buildSelectedSources(source!));
emit(
state.copyWith(
visibleSources: visible,
selectedSources:
selectedSources.map(_buildSearchResults).nonNulls.toList(),
),
);
}
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
int selectedCount = 0;
for (final childSource in chatSource.children) {
final childStatus = _recursiveUpdateSelectedStatus(childSource);
if (childStatus.isSelected) {
selectedCount++;
}
}
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
final areAllChildrenSelectedOrNoChildren =
chatSource.children.length == selectedCount;
final isAnyChildNotUnselected =
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
}
chatSource.selectedStatusNotifier.value = selectedStatus;
return selectedStatus;
}
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
chatSource.toggleIsExpanded();
if (isSelectedSection) {
for (final selectedSource in selectedSources) {
selectedSource
.findChildBySourceId(chatSource.view.id)
?.toggleIsExpanded();
}
} else {
source?.findChildBySourceId(chatSource.view.id)?.toggleIsExpanded();
}
}
@override
Future<void> close() {
source?.dispose();
for (final child in state.selectedSources) {
child.dispose();
}
for (final child in state.visibleSources) {
child.dispose();
}
return super.close();
}
}
@freezed
class ChatSettingsState with _$ChatSettingsState {
const factory ChatSettingsState({
required bool onlyUseSelectedSources,
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
factory ChatSettingsState.initial() => const ChatSettingsState(
onlyUseSelectedSources: false,
visibleSources: [],
selectedSources: [],
);
}
@@ -1,75 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.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:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_side_panel_bloc.freezed.dart';
class ChatSidePanelBloc extends Bloc<ChatSidePanelEvent, ChatSidePanelState> {
ChatSidePanelBloc({
required this.chatId,
}) : super(const ChatSidePanelState()) {
on<ChatSidePanelEvent>(
(event, emit) async {
await event.when(
selectedMetadata: (ChatMessageRefSource metadata) async {
emit(
state.copyWith(
metadata: metadata,
indicator: const ChatSidePanelIndicator.loading(),
),
);
await ViewBackendService.getView(metadata.id).fold(
(view) {
if (!isClosed) {
add(ChatSidePanelEvent.open(view));
}
},
(err) => Log.error("Failed to get view: $err"),
);
},
close: () {
emit(state.copyWith(metadata: null, isShowPanel: false));
},
open: (ViewPB view) {
emit(
state.copyWith(
indicator: ChatSidePanelIndicator.ready(view),
isShowPanel: true,
),
);
},
);
},
);
}
final String chatId;
}
@freezed
class ChatSidePanelEvent with _$ChatSidePanelEvent {
const factory ChatSidePanelEvent.selectedMetadata(
ChatMessageRefSource metadata,
) = _SelectedMetadata;
const factory ChatSidePanelEvent.close() = _Close;
const factory ChatSidePanelEvent.open(ViewPB view) = _Open;
}
@freezed
class ChatSidePanelState with _$ChatSidePanelState {
const factory ChatSidePanelState({
ChatMessageRefSource? metadata,
@Default(ChatSidePanelIndicator.loading()) ChatSidePanelIndicator indicator,
@Default(false) bool isShowPanel,
}) = _ChatSidePanelState;
}
@freezed
class ChatSidePanelIndicator with _$ChatSidePanelIndicator {
const factory ChatSidePanelIndicator.ready(ViewPB view) = _Ready;
const factory ChatSidePanelIndicator.loading() = _Loading;
}
@@ -110,7 +110,7 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
return const SizedBox();
}
return BlocProvider<ViewInfoBloc>.value(
return BlocProvider.value(
value: bloc,
child: AIChatPage(
userProfile: context.userProfile!,
@@ -1,32 +1,38 @@
import 'dart:io';
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_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/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_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
hide ChatAnimatedListReversed;
import 'package:styled_widget/styled_widget.dart';
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_side_panel_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_side_panel.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';
@@ -65,7 +71,6 @@ class AIChatPage extends StatelessWidget {
/// [AIPromptInputBloc] is used to handle the user prompt
BlocProvider(create: (_) => AIPromptInputBloc()),
BlocProvider(create: (_) => ChatSidePanelBloc(chatId: view.id)),
BlocProvider(create: (_) => ChatMemberBloc()),
],
child: Builder(
@@ -76,7 +81,7 @@ class AIChatPage extends StatelessWidget {
for (final file in detail.files) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.newFile(file.path, file.name));
.add(AIPromptInputEvent.attachFile(file.path, file.name));
}
}
},
@@ -102,110 +107,43 @@ class _ChatContentPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (UniversalPlatform.isDesktop) {
return BlocSelector<ChatSidePanelBloc, ChatSidePanelState, bool>(
selector: (state) => state.isShowPanel,
builder: (context, isShowPanel) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final sidePanelRatio = isShowPanel ? 0.33 : 0.0;
final chatWidth = constraints.maxWidth * (1 - sidePanelRatio);
final sidePanelWidth =
constraints.maxWidth * sidePanelRatio - 1.0;
return Row(
children: [
Center(
child: buildChatWidget(context)
.constrained(
maxWidth: 784,
)
.padding(horizontal: 60)
.animate(
const Duration(milliseconds: 200),
Curves.easeOut,
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
Expanded(
child: Chat(
chatController:
context.read<ChatBloc>().chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
).constrained(width: chatWidth),
if (isShowPanel) ...[
const VerticalDivider(
width: 1.0,
thickness: 1.0,
),
buildChatSidePanel()
.constrained(width: sidePanelWidth)
.animate(
const Duration(milliseconds: 200),
Curves.easeOut,
),
],
],
);
},
);
},
);
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 784),
child: buildChatWidget(context),
),
),
],
);
}
}
Widget buildChatSidePanel() {
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
builder: (context, state) {
if (state.metadata == null) {
return const SizedBox.shrink();
}
return const ChatSidePanel();
},
);
}
Widget buildChatWidget(BuildContext context) {
return ScrollConfiguration(
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(
children: [
Expanded(
child: Chat(
chatController: chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
_buildInput(context),
],
),
_buildInput(context),
],
);
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
},
),
),
),
);
}
@@ -256,14 +194,13 @@ class _ChatContentPage extends StatelessWidget {
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
return messages.isEmpty ? false : messages.last.id == message.id;
},
builder: (context, isLastMessage) {
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
@@ -272,12 +209,13 @@ class _ChatContentPage extends StatelessWidget {
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming: state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
onSelectedMetadata: (metadata) {
context
.read<ChatSidePanelBloc>()
.add(ChatSidePanelEvent.selectedMetadata(metadata));
},
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id)),
);
},
);
@@ -342,40 +280,90 @@ class _ChatContentPage extends StatelessWidget {
return state.promptResponseState == PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopAIPromptInput(
chatId: view.id,
indicateFocus: true,
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ragOnly, ids) {
if (ragOnly != chatBloc.state.onlyUseSelectedSources) {
chatBloc.add(
ChatEvent.setRagOnly(
ragOnly: ragOnly,
),
);
}
if (!listEquals(ids, chatBloc.state.selectedSourceIds)) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
}
},
)
: MobileAIPromptInput(
chatId: view.id,
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
},
),
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
} else {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
}
}
}
@@ -23,7 +23,7 @@ class ChatInputFile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocSelector<AIPromptInputBloc, AIPromptInputState, List<ChatFile>>(
selector: (state) => state.uploadFiles,
selector: (state) => state.attachedFiles,
builder: (context, files) {
if (files.isEmpty) {
return const SizedBox.shrink();
@@ -1,15 +1,18 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_library/extended_text_library.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../application/chat_input_action_control.dart';
class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
ChatInputTextSpanBuilder({
required this.inputActionControl,
required this.inputControlCubit,
this.specialTextStyle,
});
final ChatInputActionControl inputActionControl;
final ChatInputControlCubit inputControlCubit;
final TextStyle? specialTextStyle;
@override
SpecialText? createSpecialText(
@@ -22,51 +25,54 @@ class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
return null;
}
//index is end index of start flag, so text start index should be index-(flag.length-1)
if (isStart(flag, AtText.flag)) {
return AtText(
inputActionControl,
textStyle,
onTap,
start: index! - (AtText.flag.length - 1),
);
if (!isStart(flag, AtText.flag)) {
return null;
}
return null;
// index is at the end of the start flag, so the start index should be index - (flag.length - 1)
return AtText(
inputControlCubit,
specialTextStyle ?? textStyle,
onTap,
// scrubbing over text is kinda funky
start: index! - (AtText.flag.length - 1),
);
}
}
class AtText extends SpecialText {
AtText(
this.inputActionControl,
this.inputControlCubit,
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
this.start,
}) : super(flag, '', textStyle, onTap: onTap);
static const String flag = '@';
final int? start;
final ChatInputActionControl inputActionControl;
final ChatInputControlCubit inputControlCubit;
@override
bool isEnd(String value) {
return inputActionControl.tags.contains(value);
}
bool isEnd(String value) => inputControlCubit.selectedViewIds.contains(value);
@override
InlineSpan finishText() {
final TextStyle? textStyle =
this.textStyle?.copyWith(color: Colors.blue, fontSize: 15.0);
final String actualText = toString();
final String atText = toString();
final viewName = inputControlCubit.allViews
.firstWhereOrNull((view) => view.id == actualText.substring(1))
?.name ??
"";
final nonEmptyName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
return SpecialTextSpan(
text: atText,
actualText: atText,
text: "@$nonEmptyName",
actualText: actualText,
start: start!,
style: textStyle,
recognizer: (TapGestureRecognizer()
..onTap = () {
onTap?.call(atText);
}),
);
}
}
@@ -0,0 +1,204 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/view/view_service.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:flutter/material.dart';
import 'chat_mention_page_menu.dart';
Future<ViewPB?> showPageSelectorSheet(
BuildContext context, {
required bool Function(ViewPB view) filter,
}) async {
return showMobileBottomSheet<ViewPB>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (context, scrollController) {
return Expanded(
child: _MobilePageSelectorBody(
filter: filter,
scrollController: scrollController,
),
);
},
builder: (context) => const SizedBox.shrink(),
);
}
class _MobilePageSelectorBody extends StatefulWidget {
const _MobilePageSelectorBody({
this.filter,
this.scrollController,
});
final bool Function(ViewPB view)? filter;
final ScrollController? scrollController;
@override
State<_MobilePageSelectorBody> createState() =>
_MobilePageSelectorBodyState();
}
class _MobilePageSelectorBodyState extends State<_MobilePageSelectorBody> {
final textController = TextEditingController();
late final Future<List<ViewPB>> _viewsFuture = _fetchViews();
@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.document_mobilePageSelector_title.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: (_) => setState(() {}),
),
),
),
const Divider(height: 0.5, thickness: 0.5),
],
),
),
),
),
FutureBuilder(
future: _viewsFuture,
builder: (_, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SliverToBoxAdapter(
child: CircularProgressIndicator.adaptive(),
);
}
if (snapshot.hasError || snapshot.data == null) {
return SliverToBoxAdapter(
child: FlowyText(
LocaleKeys.document_mobilePageSelector_failedToLoad.tr(),
),
);
}
final views = snapshot.data!
.where((v) => widget.filter?.call(v) ?? true)
.toList();
final filtered = views.where(
(v) =>
textController.text.isEmpty ||
v.name
.toLowerCase()
.contains(textController.text.toLowerCase()),
);
if (filtered.isEmpty) {
return SliverToBoxAdapter(
child: FlowyText(
LocaleKeys.document_mobilePageSelector_noPagesFound.tr(),
),
);
}
return SliverPadding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final view = filtered.elementAt(index);
return InkWell(
onTap: () => Navigator.of(context).pop(view),
borderRadius: BorderRadius.circular(12),
splashColor: Colors.transparent,
child: Container(
height: 44,
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
MentionViewIcon(view: view),
const HSpace(8),
Expanded(
child: MentionViewTitleAndAncestors(view: view),
),
],
),
),
);
},
childCount: filtered.length,
),
),
);
},
),
],
);
}
Future<List<ViewPB>> _fetchViews() async =>
(await ViewBackendService.getAllViews()).toNullable()?.items ?? [];
}
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,429 @@
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';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
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 'package:scroll_to_index/scroll_to_index.dart';
const double _itemHeight = 44.0;
const double _noPageHeight = 20.0;
const double _fixedWidth = 360.0;
const double _maxHeight = 328.0;
class ChatInputAnchor {
ChatInputAnchor(this.anchorKey, this.layerLink);
final GlobalKey<State<StatefulWidget>> anchorKey;
final LayerLink layerLink;
}
class ChatMentionPageMenu extends StatefulWidget {
const ChatMentionPageMenu({
super.key,
required this.anchor,
required this.textController,
required this.onPageSelected,
});
final ChatInputAnchor anchor;
final TextEditingController textController;
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageMenu> createState() => _ChatMentionPageMenuState();
}
class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
context.read<ChatInputControlCubit>().refreshViews();
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatInputControlCubit, ChatInputControlState>(
builder: (context, state) {
return Stack(
children: [
CompositedTransformFollower(
link: widget.anchor.layerLink,
showWhenUnlinked: false,
offset: Offset(getPopupOffsetX(), 0.0),
followerAnchor: Alignment.bottomLeft,
child: Container(
constraints: const BoxConstraints(
minWidth: _fixedWidth,
maxWidth: _fixedWidth,
maxHeight: _maxHeight,
),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(6.0),
boxShadow: const [
BoxShadow(
color: Color(0x0A1F2329),
blurRadius: 24,
offset: Offset(0, 8),
spreadRadius: 8,
),
BoxShadow(
color: Color(0x0A1F2329),
blurRadius: 12,
offset: Offset(0, 6),
),
BoxShadow(
color: Color(0x0F1F2329),
blurRadius: 8,
offset: Offset(0, 4),
spreadRadius: -8,
),
],
),
child: TextFieldTapRegion(
child: ChatMentionPageList(
onPageSelected: widget.onPageSelected,
),
),
),
),
],
);
},
);
}
double getPopupOffsetX() {
if (widget.anchor.anchorKey.currentContext == null) {
return 0.0;
}
final cubit = context.read<ChatInputControlCubit>();
if (cubit.filterStartPosition == -1) {
return 0.0;
}
final textPosition = TextPosition(offset: cubit.filterEndPosition);
final renderBox =
widget.anchor.anchorKey.currentContext?.findRenderObject() as RenderBox;
final textPainter = TextPainter(
text: TextSpan(text: cubit.formatIntputText(widget.textController.text)),
textDirection: TextDirection.ltr,
);
textPainter.layout(
minWidth: renderBox.size.width,
maxWidth: renderBox.size.width,
);
final caretOffset = textPainter.getOffsetForCaret(textPosition, Rect.zero);
final boxes = textPainter.getBoxesForSelection(
TextSelection(
baseOffset: textPosition.offset,
extentOffset: textPosition.offset,
),
);
if (boxes.isNotEmpty) {
return boxes.last.right;
}
return caretOffset.dx;
}
}
class ChatMentionPageList extends StatefulWidget {
const ChatMentionPageList({
super.key,
required this.onPageSelected,
});
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageList> createState() => _ChatMentionPageListState();
}
class _ChatMentionPageListState extends State<ChatMentionPageList> {
final autoScrollController = SimpleAutoScrollController(
suggestedRowHeight: _itemHeight,
beginGetter: (rect) => rect.top + 8.0,
endGetter: (rect) => rect.bottom - 8.0,
);
@override
void dispose() {
autoScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocConsumer<ChatInputControlCubit, ChatInputControlState>(
listenWhen: (previous, current) {
return previous.maybeWhen(
ready: (_, pFocusedViewIndex) => current.maybeWhen(
ready: (_, cFocusedViewIndex) =>
pFocusedViewIndex != cFocusedViewIndex,
orElse: () => false,
),
orElse: () => false,
);
},
listener: (context, state) {
state.maybeWhen(
ready: (views, focusedViewIndex) {
if (focusedViewIndex == -1 || !autoScrollController.hasClients) {
return;
}
if (autoScrollController.isAutoScrolling) {
autoScrollController.position
.jumpTo(autoScrollController.position.pixels);
}
autoScrollController.scrollToIndex(
focusedViewIndex,
duration: const Duration(milliseconds: 200),
preferPosition: AutoScrollPosition.begin,
);
},
orElse: () {},
);
},
builder: (context, state) {
return state.maybeWhen(
loading: () {
return const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(
height: _noPageHeight,
child: Center(
child: CircularProgressIndicator.adaptive(),
),
),
);
},
ready: (views, focusedViewIndex) {
if (views.isEmpty) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
height: _noPageHeight,
child: Center(
child: FlowyText(
LocaleKeys.chat_inputActionNoPages.tr(),
),
),
),
);
}
return ListView.builder(
shrinkWrap: true,
controller: autoScrollController,
padding: const EdgeInsets.all(8.0),
itemCount: views.length,
itemBuilder: (context, index) {
final view = views[index];
return AutoScrollTag(
key: ValueKey("chat_mention_page_item_${view.id}"),
index: index,
controller: autoScrollController,
child: _ChatMentionPageItem(
view: view,
onTap: () => widget.onPageSelected(view),
isSelected: focusedViewIndex == index,
),
);
},
);
},
orElse: () => const SizedBox.shrink(),
);
},
);
}
}
class _ChatMentionPageItem extends StatelessWidget {
const _ChatMentionPageItem({
required this.view,
required this.isSelected,
required this.onTap,
});
final ViewPB view;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: view.name,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: FlowyHover(
isSelected: () => isSelected,
child: Container(
height: _itemHeight,
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
MentionViewIcon(view: view),
const HSpace(8.0),
Expanded(child: MentionViewTitleAndAncestors(view: view)),
],
),
),
),
),
),
);
}
}
class MentionViewIcon extends StatelessWidget {
const MentionViewIcon({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
final spaceIcon = view.buildSpaceIconSvg(context);
if (view.icon.value.isNotEmpty) {
return SizedBox(
width: 16.0,
child: EmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: 14,
),
);
}
if (view.isSpace == true && spaceIcon != null) {
return SpaceIcon(
dimension: 16.0,
svgSize: 9.68,
space: view,
cornerRadius: 4,
);
}
return FlowySvg(
view.layout.icon,
size: const Size.square(16),
color: Theme.of(context).hintColor,
);
}
}
class MentionViewTitleAndAncestors extends StatelessWidget {
const MentionViewTitleAndAncestors({
super.key,
required this.view,
});
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ViewTitleBarBloc(view: view),
child: BlocBuilder<ViewTitleBarBloc, ViewTitleBarState>(
builder: (context, state) {
final nonEmptyName = view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name;
final ancestorList = _getViewAncestorList(state.ancestors);
if (state.ancestors.isEmpty || ancestorList.trim().isEmpty) {
return FlowyText(
nonEmptyName,
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
nonEmptyName,
fontSize: 14.0,
figmaLineHeight: 20.0,
overflow: TextOverflow.ellipsis,
),
FlowyText(
ancestorList,
fontSize: 12.0,
figmaLineHeight: 16.0,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
],
);
},
),
);
}
/// see workspace/presentation/widgets/view_title_bar.dart, upon which this
/// function was based. This version doesn't include the current view in the
/// result, and returns a string rather than a list of widgets
String _getViewAncestorList(
List<ViewPB> views,
) {
const lowerBound = 2;
final upperBound = views.length - 2;
bool hasAddedEllipsis = false;
String result = "";
if (views.length <= 1) {
return "";
}
// ignore the workspace name, use section name instead in the future
// skip the workspace view
for (var i = 1; i < views.length - 1; i++) {
final view = views[i];
if (i >= lowerBound && i < upperBound) {
if (!hasAddedEllipsis) {
hasAddedEllipsis = true;
result += "… / ";
}
continue;
}
final nonEmptyName = view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name;
result += nonEmptyName;
if (i != views.length - 2) {
// if not the last one, add a divider
result += " / ";
}
}
return result;
}
}
@@ -1,10 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.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_input_action_control.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
@@ -12,39 +10,41 @@ 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 'ai_prompt_buttons.dart';
import 'chat_input_span.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({
super.key,
required this.chatId,
required this.indicateFocus,
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
required this.onUpdateSelectedSources,
});
final String chatId;
final bool indicateFocus;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(String, Map<String, dynamic>) onSubmitted;
final void Function(bool, List<String>) onUpdateSelectedSources;
@override
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
}
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
final GlobalKey _textFieldKey = GlobalKey();
final LayerLink _layerLink = LayerLink();
late final ChatInputActionControl _inputActionControl;
late final FocusNode _inputFocusNode;
late final TextEditingController _textController;
final textFieldKey = GlobalKey();
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
late SendButtonState sendButtonState;
@@ -52,40 +52,22 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
void initState() {
super.initState();
_textController = TextEditingController()
..addListener(_handleTextControllerChange);
textController.addListener(handleTextControllerChanged);
_inputFocusNode = FocusNode(
onKeyEvent: (node, event) {
if (_inputActionControl.canHandleKeyEvent(event)) {
_inputActionControl.handleKeyEvent(event);
return KeyEventResult.handled;
} else {
return _handleEnterKeyWithoutShift(
event,
_textController,
widget.isStreaming,
_handleSendPressed,
);
// refresh border color on focus change and hide menu when lost focus
focusNode.addListener(
() => setState(() {
if (!focusNode.hasFocus) {
cancelMentionPage();
}
},
)..addListener(() {
// refresh border color on focus change
if (widget.indicateFocus) {
setState(() {});
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
_inputFocusNode.requestFocus();
});
_inputActionControl = ChatInputActionControl(
chatId: widget.chatId,
textController: _textController,
textFieldFocusNode: _inputFocusNode,
}),
);
updateSendButtonState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
@@ -96,205 +78,491 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
_inputActionControl.dispose();
focusNode.dispose();
textController.dispose();
inputControlCubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: _inputFocusNode.hasFocus && widget.indicateFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: DesktopAIPromptSizes.attachedFilesBarPadding.vertical +
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: TextFieldTapRegion(
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.deleteFile(file)),
return BlocProvider.value(
value: inputControlCubit,
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
updateSelectedViews: (selectedViews) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.updateMentionedViews(selectedViews));
},
orElse: () {},
);
},
child: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return ChatMentionPageMenu(
anchor: ChatInputAnchor(textFieldKey, layerLink),
textController: textController,
onPageSelected: handlePageSelected,
);
},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
),
Stack(
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight,
maxHeight: 300,
),
child: _inputTextField(context),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: Container(
height: DesktopAIPromptSizes.actionBarHeight,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _predefinedFormatButton(),
const Spacer(),
_mentionButton(),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
),
if (UniversalPlatform.isDesktop &&
state.supportChatWithFile) ...[
_attachmentButton(),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
),
],
_sendButton(),
],
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
DesktopAIPromptSizes.attachedFilesBarPadding.vertical +
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: TextFieldTapRegion(
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
),
),
),
),
],
Stack(
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight +
DesktopAIPromptSizes.actionBarPadding.vertical,
maxHeight: 300,
),
child: inputTextField(),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
textController: textController,
overlayController: overlayController,
focusNode: focusNode,
sendButtonState: sendButtonState,
onSendPressed: handleSendPressed,
onStopStreaming: widget.onStopStreaming,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
),
),
),
],
),
],
),
),
],
),
),
);
}
void cancelMentionPage() {
if (overlayController.isShowing) {
inputControlCubit.reset();
overlayController.hide();
}
}
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (_textController.text.trim().isEmpty) {
} else if (textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
}
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
_textController.clear();
void handleSendPressed() {
if (widget.isStreaming) {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
);
textController.clear();
if (trimmedText.isEmpty) {
return;
}
// consume metadata
final ChatInputMentionMetadata mentionPageMetadata =
_inputActionControl.consumeMetaData();
final ChatInputFileMetadata fileMetadata =
context.read<AIPromptInputBloc>().consumeMetadata();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
widget.onSubmitted(trimmedText, metadata);
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
void handleTextControllerChanged() {
if (!textController.value.composing.isCollapsed) {
return;
}
// update whether send button is clickable
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;
}
// handle cases where mention a page is cancelled
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
textSelection.baseOffset < inputControlCubit.filterStartPosition;
final isCaretAfterEndOfRange =
textSelection.baseOffset > inputControlCubit.filterEndPosition;
final isTextSame = inputControlCubit.inputText == textController.text;
if (isSelectingMultipleCharacters ||
isTextSame && (isCaretBeforeStartOfRange || isCaretAfterEndOfRange)) {
cancelMentionPage();
return;
}
final previousLength = inputControlCubit.inputText.characters.length;
final currentLength = textController.text.characters.length;
// delete "@"
if (previousLength != currentLength && isCaretBeforeStartOfRange) {
cancelMentionPage();
return;
}
// handle cases where mention the filter is updated
if (previousLength != currentLength) {
final diff = currentLength - previousLength;
final newEndPosition = inputControlCubit.filterEndPosition + diff;
final newFilter = textController.text.substring(
inputControlCubit.filterStartPosition,
newEndPosition,
);
inputControlCubit.updateFilter(
textController.text,
newFilter,
newEndPosition: newEndPosition,
);
} else if (!isTextSame) {
final newFilter = textController.text.substring(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
);
inputControlCubit.updateFilter(textController.text, newFilter);
}
}
Widget _inputTextField(BuildContext context) {
return CompositedTransformTarget(
link: _layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ExtendedTextField(
key: _textFieldKey,
controller: _textController,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add(
const EdgeInsets.only(
bottom: DesktopAIPromptSizes.actionBarHeight,
),
),
void handlePageSelected(ViewPB view) {
final newText = textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
view.id,
);
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: inputControlCubit.filterStartPosition + view.id.length,
affinity: TextAffinity.upstream,
),
);
inputControlCubit.selectPage(view);
overlayController.hide();
}
Widget inputTextField() {
return Actions(
actions: buildActions(),
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return _PromptTextField(
key: textFieldKey,
cubit: inputControlCubit,
textController: textController,
textFieldFocusNode: focusNode,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
hintStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).hintColor),
isCollapsed: true,
isDense: true,
// onStartMentioningPage: () {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// inputControlCubit.startSearching(textController.value);
// overlayController.show();
// });
// },
);
},
),
),
);
}
Map<Type, Action<Intent>> buildActions() {
return {
_FocusPreviousItemIntent: CallbackAction<_FocusPreviousItemIntent>(
onInvoke: (intent) {
inputControlCubit.updateSelectionUp();
return;
},
),
_FocusNextItemIntent: CallbackAction<_FocusNextItemIntent>(
onInvoke: (intent) {
inputControlCubit.updateSelectionDown();
return;
},
),
_CancelMentionPageIntent: CallbackAction<_CancelMentionPageIntent>(
onInvoke: (intent) {
cancelMentionPage();
return;
},
),
_SubmitOrMentionPageIntent: CallbackAction<_SubmitOrMentionPageIntent>(
onInvoke: (intent) {
if (overlayController.isShowing) {
inputControlCubit.state.maybeWhen(
ready: (visibleViews, focusedViewIndex) {
if (focusedViewIndex != -1 &&
focusedViewIndex < visibleViews.length) {
handlePageSelected(visibleViews[focusedViewIndex]);
}
},
orElse: () {},
);
} else {
handleSendPressed();
}
return;
},
),
};
}
}
class _PromptTextField extends StatefulWidget {
const _PromptTextField({
super.key,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
// required this.onStartMentioningPage,
this.hintText = "",
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
// final void Function() onStartMentioningPage;
final String hintText;
@override
State<_PromptTextField> createState() => _PromptTextFieldState();
}
class _PromptTextFieldState extends State<_PromptTextField> {
bool isComposing = false;
@override
void initState() {
super.initState();
// widget.textFieldFocusNode.onKeyEvent = handleKeyEvent;
widget.textController.addListener(onTextChanged);
}
@override
void dispose() {
// widget.textFieldFocusNode.onKeyEvent = null;
widget.textController.removeListener(onTextChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: buildShortcuts(),
child: ExtendedTextField(
controller: widget.textController,
focusNode: widget.textFieldFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: DesktopAIPromptSizes.textFieldContentPadding.add(
const EdgeInsets.only(
bottom: DesktopAIPromptSizes.actionBarHeight,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputActionControl: _inputActionControl,
),
onChanged: (text) {
_handleOnTextChange(context, text);
},
),
hintText: widget.hintText,
hintStyle: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).hintColor),
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputControlCubit: widget.cubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
);
}
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// if (event.character == '@') {
// widget.onStartMentioningPage();
// }
// return KeyEventResult.ignored;
// }
void onTextChanged() {
setState(
() => isComposing = !widget.textController.value.composing.isCollapsed,
);
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
}
return const {
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(),
SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(),
SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(),
};
}
}
class _SubmitOrMentionPageIntent extends Intent {
const _SubmitOrMentionPageIntent();
}
class _CancelMentionPageIntent extends Intent {
const _CancelMentionPageIntent();
}
class _FocusPreviousItemIntent extends Intent {
const _FocusPreviousItemIntent();
}
class _FocusNextItemIntent extends Intent {
const _FocusNextItemIntent();
}
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.textController,
required this.overlayController,
required this.focusNode,
required this.sendButtonState,
required this.onSendPressed,
required this.onStopStreaming,
required this.onUpdateSelectedSources,
});
final TextEditingController textController;
final OverlayPortalController overlayController;
final FocusNode focusNode;
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final void Function(bool, List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Container(
height: DesktopAIPromptSizes.actionBarHeight,
margin: DesktopAIPromptSizes.actionBarPadding,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Row(
children: [
// predefinedFormatButton(),
const Spacer(),
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,
),
],
_sendButton(),
],
);
},
),
);
}
Future<void> _handleOnTextChange(BuildContext context, String text) async {
if (!_inputActionControl.onTextChanged(text)) {
return;
}
ChatActionsMenu(
anchor: ChatInputAnchor(
anchorKey: _textFieldKey,
layerLink: _layerLink,
),
handler: _inputActionControl,
context: context,
style: Theme.of(context).brightness == Brightness.dark
? const ChatActionsMenuStyle.dark()
: const ChatActionsMenuStyle.light(),
).show();
}
Widget _mentionButton() {
return PromptInputMentionButton(
iconSize: DesktopAIPromptSizes.actionBarIconSize,
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
onTap: () {
_textController.text += '@';
if (!_inputFocusNode.hasFocus) {
_inputFocusNode.requestFocus();
}
_handleOnTextChange(context, _textController.text);
},
Widget _selectSourcesButton(BuildContext context) {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
);
}
Widget _attachmentButton() {
// 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 {
final path = await getIt<FilePickerService>().pickFiles(
@@ -308,12 +576,10 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
for (final file in path.files) {
if (file.path != null) {
if (mounted) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.newFile(file.path!, file.name));
}
if (file.path != null && context.mounted) {
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.attachFile(file.path!, file.name));
}
}
},
@@ -322,59 +588,11 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
Widget _sendButton() {
return PromptInputSendButton(
buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
buttonSize: DesktopAIPromptSizes.actionBarHeight,
iconSize: DesktopAIPromptSizes.sendButtonSize,
state: sendButtonState,
onSendPressed: _handleSendPressed,
onStopStreaming: widget.onStopStreaming,
onSendPressed: onSendPressed,
onStopStreaming: onStopStreaming,
);
}
}
class ChatInputAnchor extends ChatAnchor {
ChatInputAnchor({
required this.anchorKey,
required this.layerLink,
});
@override
final GlobalKey<State<StatefulWidget>> anchorKey;
@override
final LayerLink layerLink;
}
/// Handles the key press event for the Enter key without Shift.
///
/// This function checks if the Enter key is pressed without either of the Shift keys.
/// If the conditions are met, it performs the action of sending a message if the
/// text controller is not in a composing range and if the event is a key down event.
///
/// - Returns: A `KeyEventResult` indicating whether the key event was handled or ignored.
KeyEventResult _handleEnterKeyWithoutShift(
KeyEvent event,
TextEditingController textController,
bool isStreaming,
void Function() handleSendPressed,
) {
if (event.physicalKey == PhysicalKeyboardKey.enter &&
!HardwareKeyboard.instance.physicalKeysPressed.any(
(el) => <PhysicalKeyboardKey>{
PhysicalKeyboardKey.shiftLeft,
PhysicalKeyboardKey.shiftRight,
}.contains(el),
)) {
if (textController.value.isComposingRangeValid) {
return KeyEventResult.ignored;
}
if (event is KeyDownEvent) {
if (!isStreaming) {
handleSendPressed();
}
}
return KeyEventResult.handled;
} else {
return KeyEventResult.ignored;
}
}
@@ -1,11 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.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_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_input_file.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input_action_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
@@ -13,9 +8,12 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_span.dart';
import '../layout_define.dart';
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({
@@ -24,21 +22,23 @@ 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();
}
class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
late final ChatInputActionControl _inputActionControl;
late final FocusNode _inputFocusNode;
late final TextEditingController _textController;
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
late SendButtonState sendButtonState;
@@ -46,20 +46,13 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
void initState() {
super.initState();
_textController = TextEditingController()
..addListener(_handleTextControllerChange);
textController.addListener(handleTextControllerChange);
// focusNode.onKeyEvent = handleKeyEvent;
_inputFocusNode = FocusNode();
WidgetsBinding.instance.addPostFrameCallback((_) {
_inputFocusNode.requestFocus();
focusNode.requestFocus();
});
_inputActionControl = ChatInputActionControl(
chatId: widget.chatId,
textController: _textController,
textFieldFocusNode: _inputFocusNode,
);
updateSendButtonState();
}
@@ -71,9 +64,9 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
@override
void dispose() {
_inputFocusNode.dispose();
_textController.dispose();
_inputActionControl.dispose();
focusNode.dispose();
textController.dispose();
inputControlCubit.close();
super.dispose();
}
@@ -81,55 +74,71 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
Widget build(BuildContext context) {
return Hero(
tag: "ai_chat_prompt",
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Theme.of(context).colorScheme.outline),
),
color: Theme.of(context).colorScheme.surface,
boxShadow: const [
BoxShadow(
blurRadius: 4.0,
offset: Offset(0, -2),
color: Color.fromRGBO(0, 0, 0, 0.05),
),
],
borderRadius: MobileAIPromptSizes.promptFrameRadius,
),
child: Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MobileAIPromptSizes.attachedFilesBarPadding.vertical +
MobileAIPromptSizes.attachedFilesPreviewHeight,
child: BlocProvider.value(
value: inputControlCubit,
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
updateSelectedViews: (selectedViews) {
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent.updateMentionedViews(selectedViews),
);
},
orElse: () {},
);
},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: Theme.of(context).colorScheme.outline),
),
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.deleteFile(file)),
),
),
Container(
constraints: const BoxConstraints(
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),
_sendButton(),
],
color: Theme.of(context).colorScheme.surface,
boxShadow: const [
BoxShadow(
blurRadius: 4.0,
offset: Offset(0, -2),
color: Color.fromRGBO(0, 0, 0, 0.05),
),
),
],
borderRadius: MobileAIPromptSizes.promptFrameRadius,
),
],
child: Column(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MobileAIPromptSizes.attachedFilesBarPadding.vertical +
MobileAIPromptSizes.attachedFilesPreviewHeight,
),
child: ChatInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
),
),
Container(
constraints: const BoxConstraints(
minHeight: MobileAIPromptSizes.textFieldMinHeight,
maxHeight: 220,
),
child: IntrinsicHeight(
child: Row(
children: [
const HSpace(8.0),
leadingButtons(context),
Expanded(
child: inputTextField(context),
),
sendButton(),
const HSpace(12.0),
],
),
),
),
],
),
),
),
),
);
@@ -138,138 +147,175 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (_textController.text.trim().isEmpty) {
} else if (textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
}
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
_textController.clear();
void handleSendPressed() {
if (widget.isStreaming) {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
);
textController.clear();
if (trimmedText.isEmpty) {
return;
}
// consume metadata
final ChatInputMentionMetadata mentionPageMetadata =
_inputActionControl.consumeMetaData();
final ChatInputFileMetadata fileMetadata =
context.read<AIPromptInputBloc>().consumeMetadata();
// combine metadata
final Map<String, dynamic> metadata = {}
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
widget.onSubmitted(trimmedText, metadata);
}
void _handleTextControllerChange() {
if (_textController.value.isComposingRangeValid) {
void handleTextControllerChange() {
if (textController.value.isComposingRangeValid) {
return;
}
// inputControlCubit.updateInputText(textController.text);
setState(() => updateSendButtonState());
}
Widget _inputTextField(BuildContext context) {
// 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
// otherwise, the page sheet animation will be blocked by the keyboard
inputControlCubit.refreshViews();
inputControlCubit.startSearching(textController.value);
if (focusNode.hasFocus) {
focusNode.unfocus();
await Future.delayed(const Duration(milliseconds: 100));
}
if (context.mounted) {
final selectedView = await showPageSelectorSheet(
context,
filter: (view) =>
!view.isSpace &&
view.layout.isDocumentView &&
view.parentViewId != view.id &&
!inputControlCubit.selectedViewIds.contains(view.id),
);
if (selectedView != null) {
final newText = textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterStartPosition,
selectedView.id,
);
textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset:
textController.selection.baseOffset + selectedView.id.length,
affinity: TextAffinity.upstream,
),
);
inputControlCubit.selectPage(selectedView);
}
focusNode.requestFocus();
inputControlCubit.reset();
}
}
Widget inputTextField(BuildContext context) {
return BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ExtendedTextField(
controller: _textController,
focusNode: _inputFocusNode,
decoration: _buildInputDecoration(state),
controller: textController,
focusNode: focusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputActionControl: _inputActionControl,
inputControlCubit: inputControlCubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
onChanged: (text) {
_handleOnTextChange(context, text);
},
);
},
);
}
InputDecoration _buildInputDecoration(AIPromptInputState state) {
return InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
isCollapsed: true,
isDense: true,
);
}
Future<void> _handleOnTextChange(BuildContext context, String text) async {
if (!_inputActionControl.onTextChanged(text)) {
return;
}
// if the focus node is on focus, unfocus it for better animation
// otherwise, the page sheet animation will be blocked by the keyboard
if (_inputFocusNode.hasFocus) {
_inputFocusNode.unfocus();
Future.delayed(const Duration(milliseconds: 100), () async {
await _referPage(_inputActionControl);
});
} else {
await _referPage(_inputActionControl);
}
}
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 (!_inputFocusNode.hasFocus) {
_inputFocusNode.requestFocus();
}
_handleOnTextChange(context, _textController.text);
},
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(
Widget sendButton() {
return Container(
alignment: Alignment.bottomCenter,
padding: const EdgeInsets.only(bottom: 8.0),
child: PromptInputSendButton(
buttonSize: MobileAIPromptSizes.sendButtonSize,
iconSize: MobileAIPromptSizes.sendButtonSize,
onSendPressed: _handleSendPressed,
onSendPressed: handleSendPressed,
onStopStreaming: widget.onStopStreaming,
state: sendButtonState,
),
);
}
}
Future<void> _referPage(ChatActionHandler handler) async {
handler.onEnter();
final selectedView = await showPageSelectorSheet(
context,
filter: (view) =>
view.layout.isDocumentView &&
!view.isSpace &&
view.parentViewId.isNotEmpty,
class _LeadingActions extends StatelessWidget {
const _LeadingActions({
required this.textController,
required this.onUpdateSelectedSources,
});
final TextEditingController textController;
final void Function(List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).cardColor,
child: PromptInputMobileSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
),
);
if (selectedView != null) {
handler.onSelected(ViewActionPage(view: selectedView));
}
handler.onExit();
_inputFocusNode.requestFocus();
}
}
@@ -0,0 +1,263 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'select_sources_menu.dart';
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
const PromptInputMobileSelectSourcesButton({
super.key,
required this.onUpdateSelectedSources,
});
final void Function(List<String>) onUpdateSelectedSources;
@override
State<PromptInputMobileSelectSourcesButton> createState() =>
_PromptInputMobileSelectSourcesButtonState();
}
class _PromptInputMobileSelectSourcesButtonState
extends State<PromptInputMobileSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
});
}
@override
void dispose() {
cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().userProfile;
final workspaceId = state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
key: ValueKey(workspaceId),
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources(spaceView);
}
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
),
);
},
),
);
},
);
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
const _MobileSelectSourcesSheetBody({
required this.scrollController,
});
final ScrollController scrollController;
@override
State<_MobileSelectSourcesSheetBody> createState() =>
_MobileSelectSourcesSheetBodyState();
}
class _MobileSelectSourcesSheetBodyState
extends State<_MobileSelectSourcesSheetBody> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandle(),
SizedBox(
height: 44.0,
child: Center(
child: FlowyText.medium(
LocaleKeys.chat_selectSources.tr(),
fontSize: 16.0,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (value) => context
.read<ChatSettingsCubit>()
.updateFilter(value),
),
),
),
const Divider(height: 0.5, thickness: 0.5),
],
),
),
),
),
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
final sources = state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: sources.length,
(context, index) {
final source = sources.elementAt(index);
return ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${source.view.id}',
),
chatSource: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 40.0,
);
},
),
);
},
),
],
);
}
}
class _Header extends SliverPersistentHeaderDelegate {
const _Header({
required this.child,
});
final Widget child;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
double get maxExtent => 120.5;
@override
double get minExtent => 120.5;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}
@@ -0,0 +1,551 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
import 'chat_mention_page_menu.dart';
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
const PromptInputDesktopSelectSourcesButton({
super.key,
required this.onUpdateSelectedSources,
});
final void Function(bool, List<String>) onUpdateSelectedSources;
@override
State<PromptInputDesktopSelectSourcesButton> createState() =>
_PromptInputDesktopSelectSourcesButtonState();
}
class _PromptInputDesktopSelectSourcesButtonState
extends State<PromptInputDesktopSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
final popoverController = PopoverController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final chatBlocState = context.read<ChatBloc>().state;
cubit
..updateSelectedSources(chatBlocState.selectedSourceIds)
..updateOnlyUseSelectedSources(chatBlocState.onlyUseSelectedSources);
});
}
@override
void dispose() {
cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateOnlyUseSelectedSources(state.onlyUseSelectedSources)
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
},
onClose: () {
widget.onUpdateSelectedSources(
cubit.state.onlyUseSelectedSources,
cubit.selectedSourceIds,
);
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
onTap: () => popoverController.show(),
),
),
);
},
),
);
}
}
class _IndicatorButton extends StatelessWidget {
const _IndicatorButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
),
const HSpace(2.0),
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return FlowyText(
state.selectedSourceIds.length.toString(),
fontSize: 14,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
);
},
),
const HSpace(2.0),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
),
),
),
);
}
}
class _PopoverContent extends StatelessWidget {
const _PopoverContent();
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
),
),
Container(
margin: const EdgeInsets.fromLTRB(8, 0, 8, 8),
height: 30,
child: FlowyButton(
text: FlowyText(
LocaleKeys.chat_onlyUseRags.tr(),
overflow: TextOverflow.ellipsis,
),
onTap: () {
context
.read<ChatSettingsCubit>()
.updateOnlyUseSelectedSources(
!state.onlyUseSelectedSources,
);
},
rightIcon: state.onlyUseSelectedSources
? FlowySvg(
FlowySvgs.check_s,
color: Theme.of(context).colorScheme.primary,
)
: null,
),
),
_buildDivider(),
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
..._buildSelectedSources(context, state),
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: _buildDivider(),
),
..._buildVisibleSources(context, state),
],
),
),
],
);
},
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 8.0,
endIndent: 8.0,
);
}
Iterable<Widget> _buildSelectedSources(
BuildContext context,
ChatSettingsState state,
) {
return state.selectedSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
}
}
class ChatSourceTreeItem extends StatefulWidget {
const ChatSourceTreeItem({
super.key,
required this.chatSource,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
required this.onSelected,
required this.height,
this.showCheckbox = true,
});
final ChatSource chatSource;
/// nested level of the view item
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final void Function(ChatSource chatSource) onSelected;
final double height;
final bool showCheckbox;
@override
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
}
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: widget.height,
child: ChatSourceTreeItemInner(
chatSource: widget.chatSource,
level: widget.level,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
showCheckbox: widget.showCheckbox,
onSelected: widget.onSelected,
),
);
final disabledEnabledChild =
widget.chatSource.ignoreStatus == IgnoreViewType.disable
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.chatSource.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
return ValueListenableBuilder(
valueListenable: widget.chatSource.isExpandedNotifier,
builder: (context, isExpanded, child) {
// filter the child views that should be ignored
final childViews = widget.chatSource.children
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.toList();
if (!isExpanded || childViews.isEmpty) {
return disabledEnabledChild;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
disabledEnabledChild,
...childViews.map(
(childSource) => ChatSourceTreeItem(
key: ValueKey(
'select_sources_tree_item_${childSource.view.id}',
),
chatSource: childSource,
level: widget.level + 1,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
onSelected: widget.onSelected,
height: widget.height,
showCheckbox: widget.showCheckbox,
),
),
],
);
},
);
}
}
class ChatSourceTreeItemInner extends StatelessWidget {
const ChatSourceTreeItemInner({
super.key,
required this.chatSource,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
required this.showCheckbox,
this.onSelected,
});
final ChatSource chatSource;
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final bool showCheckbox;
final void Function(ChatSource)? onSelected;
@override
Widget build(BuildContext context) {
return FlowyHover(
style: HoverStyle(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onSelected?.call(chatSource),
child: Row(
children: [
const HSpace(4.0),
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
// builds the >, ^ or · button
ToggleIsExpandedButton(
chatSource: chatSource,
isSelectedSection: isSelectedSection,
),
const HSpace(2.0),
// checkbox
if (!chatSource.view.isSpace && showCheckbox) ...[
SourceSelectedStatusCheckbox(
chatSource: chatSource,
),
const HSpace(4.0),
],
// icon
MentionViewIcon(
view: chatSource.view,
),
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
chatSource.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
),
),
],
),
),
);
}
}
class ToggleIsExpandedButton extends StatelessWidget {
const ToggleIsExpandedButton({
super.key,
required this.chatSource,
required this.isSelectedSection,
});
final ChatSource chatSource;
final bool isSelectedSection;
@override
Widget build(BuildContext context) {
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
return const _DotIconWidget();
}
if (chatSource.children.isEmpty) {
return const SizedBox.square(dimension: 16.0);
}
return FlowyHover(
child: GestureDetector(
child: ValueListenableBuilder(
valueListenable: chatSource.isExpandedNotifier,
builder: (context, value, _) => FlowySvg(
value
? FlowySvgs.view_item_expand_s
: FlowySvgs.view_item_unexpand_s,
size: const Size.square(16.0),
),
),
onTap: () => context
.read<ChatSettingsCubit>()
.toggleIsExpanded(chatSource, isSelectedSection),
),
);
}
}
class _DotIconWidget extends StatelessWidget {
const _DotIconWidget();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(6.0),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).iconTheme.color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
class SourceSelectedStatusCheckbox extends StatelessWidget {
const SourceSelectedStatusCheckbox({
super.key,
required this.chatSource,
});
final ChatSource chatSource;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chatSource.selectedStatusNotifier,
builder: (context, selectedStatus, _) => FlowySvg(
switch (selectedStatus) {
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
},
size: const Size.square(18.0),
blendMode: null,
),
);
}
}
@@ -1,320 +0,0 @@
import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_action_control.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/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
abstract class ChatActionHandler {
void onEnter();
void onSelected(ChatInputMention page);
void onExit();
ChatInputActionBloc get commandBloc;
void onFilter(String filter);
double actionMenuOffsetX();
}
abstract class ChatAnchor {
GlobalKey get anchorKey;
LayerLink get layerLink;
}
const int _itemHeight = 34;
const int _itemVerticalPadding = 4;
const int _noPageHeight = 20;
class ChatActionsMenu {
ChatActionsMenu({
required this.anchor,
required this.context,
required this.handler,
required this.style,
});
final BuildContext context;
final ChatAnchor anchor;
final ChatActionsMenuStyle style;
final ChatActionHandler handler;
OverlayEntry? _overlayEntry;
void dismiss() {
_overlayEntry?.remove();
_overlayEntry = null;
handler.onExit();
}
void show() {
WidgetsBinding.instance.addPostFrameCallback((_) => _show());
}
void _show() {
if (_overlayEntry != null) {
dismiss();
}
if (anchor.anchorKey.currentContext == null) {
return;
}
handler.onEnter();
const double maxHeight = 300;
_overlayEntry = OverlayEntry(
builder: (context) => BlocProvider.value(
value: handler.commandBloc,
child: BlocBuilder<ChatInputActionBloc, ChatInputActionState>(
builder: (context, state) {
final height = min(
max(
state.pages.length * (_itemHeight + _itemVerticalPadding),
_noPageHeight,
),
maxHeight,
);
final isLoading =
state.indicator == const ChatActionMenuIndicator.loading();
return Stack(
children: [
CompositedTransformFollower(
link: anchor.layerLink,
showWhenUnlinked: false,
offset: Offset(handler.actionMenuOffsetX(), -height - 4),
child: Material(
elevation: 4.0,
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 200,
maxWidth: 200,
maxHeight: maxHeight,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6.0),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2,
vertical: 2,
),
child: ActionList(
isLoading: isLoading,
handler: handler,
onDismiss: () => dismiss(),
pages: state.pages,
),
),
),
),
),
),
],
);
},
),
),
);
Overlay.of(context).insert(_overlayEntry!);
}
}
class _ActionItem extends StatelessWidget {
const _ActionItem({
required this.item,
required this.onTap,
required this.isSelected,
});
final ChatInputMention item;
final VoidCallback? onTap;
final bool isSelected;
@override
Widget build(BuildContext context) {
return Container(
height: _itemHeight.toDouble(),
padding: const EdgeInsets.symmetric(vertical: _itemVerticalPadding / 2.0),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(4.0),
),
child: FlowyTooltip(
message: item.title,
child: FlowyButton(
leftIcon: item.icon,
margin: const EdgeInsets.symmetric(horizontal: 6),
iconPadding: 10.0,
text: FlowyText(
item.title.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: item.title,
lineHeight: 1.0,
overflow: TextOverflow.ellipsis,
),
onTap: onTap,
),
),
);
}
}
class ActionList extends StatefulWidget {
const ActionList({
super.key,
required this.handler,
required this.onDismiss,
required this.pages,
required this.isLoading,
});
final ChatActionHandler handler;
final VoidCallback? onDismiss;
final List<ChatInputMention> pages;
final bool isLoading;
@override
State<ActionList> createState() => _ActionListState();
}
class _ActionListState extends State<ActionList> {
int _selectedIndex = 0;
final _scrollController = AutoScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
KeyEventResult _handleKeyPress(logicalKey) {
bool isHandle = false;
setState(() {
if (logicalKey == PhysicalKeyboardKey.arrowDown) {
_selectedIndex = (_selectedIndex + 1) % widget.pages.length;
_scrollToSelectedIndex();
isHandle = true;
} else if (logicalKey == PhysicalKeyboardKey.arrowUp) {
_selectedIndex =
(_selectedIndex - 1 + widget.pages.length) % widget.pages.length;
_scrollToSelectedIndex();
isHandle = true;
} else if (logicalKey == PhysicalKeyboardKey.enter) {
widget.handler.onSelected(widget.pages[_selectedIndex]);
widget.onDismiss?.call();
isHandle = true;
} else if (logicalKey == PhysicalKeyboardKey.escape) {
widget.onDismiss?.call();
isHandle = true;
}
});
return isHandle ? KeyEventResult.handled : KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return BlocListener<ChatInputActionBloc, ChatInputActionState>(
listenWhen: (previous, current) =>
previous.keyboardKey != current.keyboardKey,
listener: (context, state) {
if (state.keyboardKey != null) {
_handleKeyPress(state.keyboardKey!.physicalKey);
}
},
child: ListView(
shrinkWrap: true,
controller: _scrollController,
padding: const EdgeInsets.all(4),
children: _buildPages(),
),
);
}
List<Widget> _buildPages() {
if (widget.isLoading) {
return [
SizedBox(
height: _noPageHeight.toDouble(),
child: const Center(child: CircularProgressIndicator.adaptive()),
),
];
}
if (widget.pages.isEmpty) {
return [
SizedBox(
height: _noPageHeight.toDouble(),
child:
Center(child: FlowyText(LocaleKeys.chat_inputActionNoPages.tr())),
),
];
}
return widget.pages.asMap().entries.map((entry) {
final index = entry.key;
final ChatInputMention item = entry.value;
return AutoScrollTag(
key: ValueKey(item.pageId),
index: index,
controller: _scrollController,
child: _ActionItem(
item: item,
onTap: () {
widget.handler.onSelected(item);
widget.onDismiss?.call();
},
isSelected: _selectedIndex == index,
),
);
}).toList();
}
void _scrollToSelectedIndex() {
_scrollController.scrollToIndex(
_selectedIndex,
duration: const Duration(milliseconds: 200),
preferPosition: AutoScrollPosition.begin,
);
}
}
class ChatActionsMenuStyle {
ChatActionsMenuStyle({
required this.backgroundColor,
required this.groupTextColor,
required this.menuItemTextColor,
required this.menuItemSelectedColor,
required this.menuItemSelectedTextColor,
});
const ChatActionsMenuStyle.light()
: backgroundColor = Colors.white,
groupTextColor = const Color(0xFF555555),
menuItemTextColor = const Color(0xFF333333),
menuItemSelectedColor = const Color(0xFFE0F8FF),
menuItemSelectedTextColor = const Color.fromARGB(255, 56, 91, 247);
const ChatActionsMenuStyle.dark()
: backgroundColor = const Color(0xFF282E3A),
groupTextColor = const Color(0xFFBBC3CD),
menuItemTextColor = const Color(0xFFBBC3CD),
menuItemSelectedColor = const Color(0xFF00BCF0),
menuItemSelectedTextColor = const Color(0xFF131720);
final Color backgroundColor;
final Color groupTextColor;
final Color menuItemTextColor;
final Color menuItemSelectedColor;
final Color menuItemSelectedTextColor;
}
@@ -1,50 +0,0 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_side_panel_bloc.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatSidePanel extends StatelessWidget {
const ChatSidePanel({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSidePanelBloc, ChatSidePanelState>(
builder: (context, state) {
return state.indicator.when(
loading: () {
return const CircularProgressIndicator.adaptive();
},
ready: (view) {
final plugin = view.plugin();
plugin.init();
final pluginContext = PluginContext();
final child = plugin.widgetBuilder
.buildWidget(context: pluginContext, shrinkWrap: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.show_menu_s),
onPressed: () {
context
.read<ChatSidePanelBloc>()
.add(const ChatSidePanelEvent.close());
},
),
),
const VSpace(6),
Expanded(child: child),
],
);
},
);
},
);
}
}
@@ -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));
}
@@ -8,6 +8,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_editor_style.dart';
@@ -106,7 +107,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
shrinkWrap: true,
// the editor is not editable in the chat
editable: false,
disableKeyboardService: true,
disableKeyboardService: UniversalPlatform.isMobile,
editorStyle: editorStyle,
editorScrollController: scrollController,
blockComponentBuilders: blockBuilders,
@@ -0,0 +1,451 @@
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import '../chat_input/select_sources_menu.dart';
import '../layout_define.dart';
import 'message_util.dart';
class AIMessageActionBar extends StatelessWidget {
const AIMessageActionBar({
super.key,
required this.message,
required this.showDecoration,
this.onRegenerate,
this.onOverrideVisibility,
});
final Message message;
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final child = SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () =>
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
children: _buildChildren(),
);
return showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.hoverActionBarRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 8,
spreadRadius: 2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
],
),
child: child,
)
: child;
}
List<Widget> _buildChildren() {
return [
CopyButton(
isInHoverBar: showDecoration,
textMessage: message as TextMessage,
),
RegenerateButton(
isInHoverBar: showDecoration,
onTap: () => onRegenerate?.call(),
),
SaveToPageButton(
textMessage: message as TextMessage,
isInHoverBar: showDecoration,
onOverrideVisibility: onOverrideVisibility,
),
];
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.isInHoverBar,
required this.textMessage,
});
final bool isInHoverBar;
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.copy_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
}
}
class RegenerateButton extends StatelessWidget {
const RegenerateButton({
super.key,
required this.isInHoverBar,
required this.onTap,
});
final bool isInHoverBar;
final void Function() onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_regenerate.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_undo_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: onTap,
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
required this.textMessage,
required this.isInHoverBar,
this.onOverrideVisibility,
});
final TextMessage textMessage;
final bool isInHoverBar;
final void Function(bool)? onOverrideVisibility;
@override
State<SaveToPageButton> createState() => _SaveToPageButtonState();
}
class _SaveToPageButtonState extends State<SaveToPageButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider(
create: (context) => ChatSettingsCubit(),
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
onClose: () {
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
widget.onOverrideVisibility?.call(false);
},
child: buildButton(context, spaceView),
popupBuilder: (_) => buildPopover(context),
);
},
),
);
}
Widget buildButton(BuildContext context, ViewPB? spaceView) {
return FlowyTooltip(
message: LocaleKeys.chat_addToPageButton.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_add_to_page_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final documentId = getOpenedDocumentId();
if (documentId != null) {
await onAddToExistingPage(documentId);
DocumentBloc.findOpen(documentId)?.forceReloadDocumentState();
} else {
widget.onOverrideVisibility?.call(true);
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources(spaceView);
}
popoverController.show();
}
},
),
);
}
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: _SaveToPagePopoverContent(
onAddToNewPage: () {
addMessageToNewPage(context);
popoverController.close();
},
onAddToExistingPage: (documentId) async {
popoverController.close();
await onAddToExistingPage(documentId);
final view =
await ViewBackendService.getView(documentId).toNullable();
if (context.mounted) {
openPageFromMessage(context, view);
}
},
),
);
}
Future<void> onAddToExistingPage(String documentId) async {
await ChatEditDocumentService.addMessageToPage(
documentId,
widget.textMessage,
);
await Future.delayed(const Duration(milliseconds: 500));
}
void addMessageToNewPage(BuildContext context) async {
final chatView = await ViewBackendService.getView(
context.read<ChatAIMessageBloc>().chatId,
).toNullable();
if (chatView != null) {
final newView = await ChatEditDocumentService.saveMessagesToNewPage(
chatView.nameOrDefault,
chatView.parentViewId,
[widget.textMessage],
);
if (context.mounted) {
openPageFromMessage(context, newView);
}
}
}
String? getOpenedDocumentId() {
final pageManager = getIt<TabsBloc>().state.currentPageManager;
if (!pageManager.showSecondaryPluginNotifier.value) {
return null;
}
return pageManager.secondaryNotifier.plugin.id;
}
}
class _SaveToPagePopoverContent extends StatelessWidget {
const _SaveToPagePopoverContent({
required this.onAddToNewPage,
required this.onAddToExistingPage,
});
final void Function() onAddToNewPage;
final void Function(String) onAddToExistingPage;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 24,
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: FlowyText(
LocaleKeys.chat_addToPageTitle.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
),
),
_buildDivider(),
Expanded(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
),
),
_buildDivider(),
_addToNewPageButton(context),
],
);
},
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 12.0,
endIndent: 12.0,
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'save_to_page_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: (source) {
if (!source.view.isSpace) {
onAddToExistingPage(source.view.id);
}
},
height: 30.0,
),
);
}
Widget _addToNewPageButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
height: 30,
child: FlowyButton(
iconPadding: 8,
onTap: onAddToNewPage,
text: FlowyText(
LocaleKeys.chat_addToNewPage.tr(),
figmaLineHeight: 20,
),
leftIcon: FlowySvg(
FlowySvgs.add_m,
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
),
),
);
}
}
@@ -4,20 +4,25 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
import 'ai_message_action_bar.dart';
import 'message_util.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
/// the actions will be displayed below the response if the response is the
@@ -30,12 +35,14 @@ class ChatAIMessageBubble extends StatelessWidget {
required this.child,
required this.showActions,
this.isLastMessage = false,
this.onRegenerate,
});
final Message message;
final Widget child;
final bool showActions;
final bool isLastMessage;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -60,6 +67,7 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapBottomActions(Widget child) {
return ChatAIBottomInlineActions(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -67,6 +75,7 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapHover(Widget child) {
return ChatAIMessageHover(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -74,6 +83,7 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapPopMenu(Widget child) {
return ChatAIMessagePopup(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -84,10 +94,12 @@ class ChatAIBottomInlineActions extends StatelessWidget {
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -101,13 +113,10 @@ class ChatAIBottomInlineActions extends StatelessWidget {
start: DesktopAIConvoSizes.avatarSize +
DesktopAIConvoSizes.avatarAndChatBubbleSpacing,
),
child: AIResponseActionBar(
child: AIMessageActionBar(
message: message,
showDecoration: false,
children: [
CopyButton(
textMessage: message as TextMessage,
),
],
onRegenerate: onRegenerate,
),
),
const VSpace(32.0),
@@ -121,10 +130,12 @@ class ChatAIMessageHover extends StatefulWidget {
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@@ -136,6 +147,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
bool hoverBubble = false;
bool hoverActionBar = false;
bool overrideVisibility = false;
ScrollPosition? scrollPosition;
@@ -194,19 +206,20 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
}
},
child: Container(
constraints: const BoxConstraints(
constraints: BoxConstraints(
maxWidth: 784,
maxHeight: 28,
maxHeight: DesktopAIConvoSizes.actionBarIconSize +
DesktopAIConvoSizes.hoverActionBarPadding.vertical,
),
alignment: Alignment.topLeft,
child: hoverBubble || hoverActionBar
? AIResponseActionBar(
child: hoverBubble || hoverActionBar || overrideVisibility
? AIMessageActionBar(
message: widget.message,
showDecoration: true,
children: [
CopyButton(
textMessage: widget.message as TextMessage,
),
],
onRegenerate: widget.onRegenerate,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
)
: null,
),
@@ -255,7 +268,10 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
final messageOffset = messageRenderBox.localToGlobal(Offset.zero);
final messageHeight = messageRenderBox.size.height;
return messageOffset.dy + messageHeight + 28 <=
return messageOffset.dy +
messageHeight +
DesktopAIConvoSizes.actionBarIconSize +
DesktopAIConvoSizes.hoverActionBarPadding.vertical <=
scrollableOffset.dy + scrollableHeight;
}
@@ -266,119 +282,17 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
}
}
class AIResponseActionBar extends StatelessWidget {
const AIResponseActionBar({
super.key,
required this.showDecoration,
required this.children,
});
final List<Widget> children;
final bool showDecoration;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final child = SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () =>
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
children: children,
);
return showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 8,
spreadRadius: 2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
],
),
child: child,
)
: child;
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.textMessage,
});
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.copy_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
}
}
class ChatAIMessagePopup extends StatelessWidget {
const ChatAIMessagePopup({
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -393,41 +307,91 @@ 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),
],
);
},
);
},
child: IgnorePointer(
child: child,
),
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,7 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.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';
@@ -43,6 +42,7 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
child: FlowyButton(
margin: const EdgeInsets.all(4.0),
useIntrinsicWidth: true,
hoverColor: Colors.transparent,
radius: BorderRadius.circular(8.0),
text: FlowyText(
LocaleKeys.chat_referenceSource.plural(
@@ -87,11 +87,7 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
disable: m.source != appflowySource,
onTap: () {
if (m.source != appflowySource) {
return;
}
widget.onSelectedMetadata?.call(m);
},
),
@@ -8,12 +8,13 @@ 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 'package:universal_platform/universal_platform.dart';
import '../chat_loading.dart';
import '../layout_define.dart';
import 'ai_markdown_text.dart';
import 'ai_message_bubble.dart';
import 'ai_metadata.dart';
import 'loading_indicator.dart';
import 'error_text_message.dart';
/// [ChatAIMessageWidget] includes both the text of the AI response as well as
@@ -32,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;
@@ -44,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
@@ -57,29 +62,38 @@ class ChatAIMessageWidget extends StatelessWidget {
),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
final loadingText =
state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr();
return Padding(
padding: AIChatUILayout.messageMargin,
child: state.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: const ChatAILoading(),
child: ChatAILoading(text: loadingText),
),
ready: () {
return state.text.isEmpty
? ChatAIMessageBubble(
message: message,
showActions: false,
child: const ChatAILoading(),
child: ChatAILoading(text: loadingText),
)
: 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: [
AIMarkdownText(markdown: state.text),
IgnorePointer(
ignoring: UniversalPlatform.isMobile,
child: AIMarkdownText(markdown: state.text),
),
if (state.sources.isNotEmpty)
AIMessageMetadata(
sources: state.sources,
@@ -94,11 +108,6 @@ class ChatAIMessageWidget extends StatelessWidget {
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
onRetry: () {
context
.read<ChatAIMessageBloc>()
.add(const ChatAIMessageEvent.retry());
},
);
},
onAIResponseLimit: () {
@@ -1,18 +1,16 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.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_animate/flutter_animate.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// An animated generating indicator for an AI response
class ChatAILoading extends StatelessWidget {
const ChatAILoading({
super.key,
this.text = "",
this.duration = const Duration(seconds: 1),
});
final String text;
final Duration duration;
@override
@@ -27,14 +25,9 @@ class ChatAILoading extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 4.0),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
return FlowyText(
state.progress?.step ??
LocaleKeys.chat_generatingResponse.tr(),
color: Theme.of(context).hintColor,
);
},
child: FlowyText(
text,
color: Theme.of(context).hintColor,
),
),
buildDot(const Color(0xFF9327FF))
@@ -0,0 +1,24 @@
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flutter/widgets.dart';
import 'package:universal_platform/universal_platform.dart';
/// Opens a message in the right hand sidebar on desktop, and push the page
/// on mobile
void openPageFromMessage(BuildContext context, ViewPB? view) {
if (view == null) {
return;
}
if (UniversalPlatform.isDesktop) {
getIt<TabsBloc>().add(
TabsEvent.openSecondaryPlugin(
plugin: view.plugin(),
),
);
} else {
context.pushView(view);
}
}
@@ -1,6 +1,9 @@
import 'dart:math';
import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -8,23 +11,27 @@ import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
// use a global value to store the selected emoji to prevent reloading every time.
EmojiData? kCachedEmojiData;
const _kRecentEmojiCategoryId = 'Recent';
class FlowyEmojiPicker extends StatefulWidget {
const FlowyEmojiPicker({
super.key,
required this.onEmojiSelected,
this.emojiPerLine = 9,
this.ensureFocus = false,
});
final EmojiSelectedCallback onEmojiSelected;
final int emojiPerLine;
final bool ensureFocus;
@override
State<FlowyEmojiPicker> createState() => _FlowyEmojiPickerState();
}
class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
EmojiData? emojiData;
late EmojiData emojiData;
bool loaded = false;
@override
void initState() {
@@ -32,12 +39,12 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
// load the emoji data from cache if it's available
if (kCachedEmojiData != null) {
emojiData = kCachedEmojiData;
loadEmojis(kCachedEmojiData!);
} else {
EmojiData.builtIn().then(
(value) {
kCachedEmojiData = value;
setState(() => emojiData = value);
loadEmojis(value);
},
);
}
@@ -45,7 +52,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
@override
Widget build(BuildContext context) {
if (emojiData == null) {
if (!loaded) {
return const Center(
child: SizedBox.square(
dimension: 24.0,
@@ -57,21 +64,19 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
}
return EmojiPicker(
emojiData: emojiData!,
emojiData: emojiData,
configuration: EmojiPickerConfiguration(
showTabs: false,
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
perLine: widget.emojiPerLine,
),
onEmojiSelected: widget.onEmojiSelected,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
headerBuilder: (context, category) {
return FlowyEmojiHeader(
category: category,
);
onEmojiSelected: (id, emoji) {
widget.onEmojiSelected.call(id, emoji);
RecentIcons.putEmoji(id);
},
padding: const EdgeInsets.symmetric(horizontal: 16.0),
headerBuilder: (_, category) => FlowyEmojiHeader(category: category),
itemBuilder: (context, emojiId, emoji, callback) {
final name = emojiData?.emojis[emojiId]?.name ?? '';
final name = emojiData.emojis[emojiId]?.name ?? '';
return SizedBox.square(
dimension: 36.0,
child: FlowyButton(
@@ -79,6 +84,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
radius: Corners.s8Border,
text: FlowyTooltip(
message: name,
preferBelow: false,
child: FlowyText.emoji(
emoji,
fontSize: 24.0,
@@ -92,7 +98,8 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: FlowyEmojiSearchBar(
emojiData: emojiData!,
emojiData: emojiData,
ensureFocus: widget.ensureFocus,
onKeywordChanged: (value) {
keyword.value = value;
},
@@ -105,4 +112,24 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
},
);
}
void loadEmojis(EmojiData data) {
RecentIcons.getEmojiIds().then((v) {
if (v.isEmpty) {
emojiData = data;
setState(() => loaded = true);
return;
}
final categories = List.of(data.categories);
categories.insert(
0,
Category(
id: _kRecentEmojiCategoryId,
emojiIds: v.sublist(0, min(widget.emojiPerLine, v.length)),
),
);
emojiData = EmojiData(categories: categories, emojis: data.emojis);
setState(() => loaded = true);
});
}
}
@@ -1,23 +1,40 @@
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../generated/locale_keys.g.dart';
import '../../../mobile/presentation/base/app_bar/app_bar.dart';
import '../../../shared/icon_emoji_picker/tab.dart';
class MobileEmojiPickerScreen extends StatelessWidget {
const MobileEmojiPickerScreen({super.key, this.title});
const MobileEmojiPickerScreen({
super.key,
this.title,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
final String? title;
final List<PickerTabType> tabs;
static const routeName = '/emoji_picker';
static const pageTitle = 'title';
static const selectTabs = 'tabs';
@override
Widget build(BuildContext context) {
return IconPickerPage(
title: title,
onSelected: (result) {
context.pop<EmojiPickerResult>(result);
},
return Scaffold(
appBar: FlowyAppBar(
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
),
body: SafeArea(
child: FlowyIconEmojiPicker(
tabs: tabs,
onSelectedEmoji: (r) {
context.pop<EmojiIconData>(r);
},
),
),
);
}
}
@@ -1,28 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class IconPickerPage extends StatelessWidget {
const IconPickerPage({
super.key,
this.title,
required this.onSelected,
});
final void Function(EmojiPickerResult) onSelected;
final String? title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
),
body: SafeArea(
child: FlowyIconEmojiPicker(onSelectedEmoji: onSelected),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:flutter/material.dart';
import '../../../generated/flowy_svgs.g.dart';
class IconWidget extends StatelessWidget {
const IconWidget({
super.key,
required this.size,
required this.data,
});
final IconsData data;
final double size;
@override
Widget build(BuildContext context) {
final colorValue = int.tryParse(data.color ?? '');
Color? color;
if (colorValue != null) {
color = Color(colorValue);
}
return FlowySvg.string(
data.iconContent,
size: Size.square(size),
color: color,
);
}
}
@@ -54,6 +54,7 @@ class BoardPageTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
key: _makeValueKey(controller),
view: view,
databaseController: controller,
shrinkWrap: shrinkWrap,
)
: MobileBoardPage(
key: _makeValueKey(controller),
@@ -98,6 +99,7 @@ class DesktopBoardPage extends StatefulWidget {
required this.view,
required this.databaseController,
this.onEditStateChanged,
this.shrinkWrap = false,
});
final ViewPB view;
@@ -107,6 +109,9 @@ class DesktopBoardPage extends StatefulWidget {
/// Called when edit state changed
final VoidCallback? onEditStateChanged;
/// If true, the board will shrink wrap its content
final bool shrinkWrap;
@override
State<DesktopBoardPage> createState() => _DesktopBoardPageState();
}
@@ -187,24 +192,17 @@ class _DesktopBoardPageState extends State<DesktopBoardPage> {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: _boardBloc,
),
BlocProvider.value(
value: _boardActionsCubit,
),
BlocProvider<BoardBloc>.value(value: _boardBloc),
BlocProvider.value(value: _boardActionsCubit),
],
child: BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) => state.maybeMap(
loading: (_) => const Center(
child: CircularProgressIndicator.adaptive(),
),
error: (err) => Center(
child: AppFlowyErrorPage(
error: err.error,
),
),
error: (err) => Center(child: AppFlowyErrorPage(error: err.error)),
orElse: () => _BoardContent(
shrinkWrap: widget.shrinkWrap,
onEditStateChanged: widget.onEditStateChanged,
focusScope: _focusScope,
boardController: _boardController,
@@ -243,11 +241,13 @@ class _BoardContent extends StatefulWidget {
required this.boardController,
required this.focusScope,
this.onEditStateChanged,
this.shrinkWrap = false,
});
final AppFlowyBoardController boardController;
final BoardFocusScope focusScope;
final VoidCallback? onEditStateChanged;
final bool shrinkWrap;
@override
State<_BoardContent> createState() => _BoardContentState();
@@ -358,12 +358,8 @@ class _BoardContentState extends State<_BoardContent> {
),
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
BlocProvider.value(value: context.read<BoardBloc>()),
BlocProvider.value(value: context.read<BoardActionsCubit>()),
],
child: BoardColumnFooter(
columnData: groupData,
@@ -20,13 +20,20 @@ import '../../application/database_controller.dart';
part 'grid_bloc.freezed.dart';
class GridBloc extends Bloc<GridEvent, GridState> {
GridBloc({required ViewPB view, required this.databaseController})
: super(GridState.initial(view.id)) {
GridBloc({
required ViewPB view,
required this.databaseController,
this.shrinkWrapped = false,
}) : super(GridState.initial(view.id)) {
_dispatch();
}
final DatabaseController databaseController;
/// When true will emit the count of visible rows to show
///
final bool shrinkWrapped;
String get viewId => databaseController.viewId;
UserProfilePB? _userProfile;
@@ -64,12 +71,22 @@ class GridBloc extends Bloc<GridEvent, GridState> {
);
},
createRow: (openRowDetail) async {
final result = await RowBackendService.createRow(viewId: viewId);
final lastVisibleRowId =
shrinkWrapped ? state.lastVisibleRow?.rowId : null;
final result = await RowBackendService.createRow(
viewId: viewId,
position: lastVisibleRowId != null
? OrderObjectPositionTypePB.After
: null,
targetRowId: lastVisibleRowId,
);
result.fold(
(createdRow) => emit(
state.copyWith(
createdRow: createdRow,
openRowDetail: openRowDetail ?? false,
visibleRows: state.visibleRows + 1,
),
),
(err) => Log.error(err),
@@ -93,11 +110,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
databaseController.moveRow(fromRowId: fromRow, toRowId: toRow);
},
didReceiveFieldUpdate: (fields) {
emit(
state.copyWith(
fields: fields,
),
);
emit(state.copyWith(fields: fields));
},
didLoadRows: (newRowInfos, reason) {
emit(
@@ -109,17 +122,13 @@ class GridBloc extends Bloc<GridEvent, GridState> {
);
},
didReceveFilters: (filters) {
emit(
state.copyWith(filters: filters),
);
emit(state.copyWith(filters: filters));
},
didReceveSorts: (sorts) {
emit(
state.copyWith(
reorderable: sorts.isEmpty,
sorts: sorts,
),
);
emit(state.copyWith(reorderable: sorts.isEmpty, sorts: sorts));
},
loadMoreRows: () {
emit(state.copyWith(visibleRows: state.visibleRows + 25));
},
);
},
@@ -204,11 +213,11 @@ class GridEvent with _$GridEvent {
const factory GridEvent.didReceiveFieldUpdate(
List<FieldInfo> fields,
) = _DidReceiveFieldUpdate;
const factory GridEvent.didReceveFilters(List<DatabaseFilter> filters) =
_DidReceiveFilters;
const factory GridEvent.didReceveSorts(List<DatabaseSort> sorts) =
_DidReceiveSorts;
const factory GridEvent.loadMoreRows() = _LoadMoreRows;
}
@freezed
@@ -225,6 +234,7 @@ class GridState with _$GridState {
required List<DatabaseSort> sorts,
required List<DatabaseFilter> filters,
required bool openRowDetail,
@Default(0) int visibleRows,
}) = _GridState;
factory GridState.initial(String viewId) => GridState(
@@ -239,5 +249,19 @@ class GridState with _$GridState {
filters: [],
sorts: [],
openRowDetail: false,
visibleRows: 25,
);
}
extension _LastVisibleRow on GridState {
/// Returns the last visible [RowInfo] in the list of [rowInfos].
/// Only returns if the visibleRows is less than the rowCount, otherwise returns null.
///
RowInfo? get lastVisibleRow {
if (visibleRows < rowCount) {
return rowInfos[visibleRows - 1];
}
return null;
}
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
@@ -8,8 +9,6 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/domain/sort_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/setting_menu.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
@@ -67,6 +66,7 @@ class DesktopGridTabBarBuilderImpl extends DatabaseTabBarItemBuilder {
view: view,
databaseController: controller,
initialRowId: initialRowId,
shrinkWrap: shrinkWrap,
);
}
@@ -110,12 +110,14 @@ class GridPage 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<GridPage> createState() => _GridPageState();
@@ -124,13 +126,22 @@ class GridPage extends StatefulWidget {
class _GridPageState extends State<GridPage> {
bool _didOpenInitialRow = false;
late final GridBloc gridBloc = GridBloc(
view: widget.view,
databaseController: widget.databaseController,
shrinkWrapped: widget.shrinkWrap,
)..add(const GridEvent.initial());
@override
void dispose() {
gridBloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider<GridBloc>(
create: (context) => GridBloc(
view: widget.view,
databaseController: widget.databaseController,
)..add(const GridEvent.initial()),
create: (_) => gridBloc,
child: BlocListener<ActionNavigationBloc, ActionNavigationState>(
listener: (context, state) {
final action = state.action;
@@ -147,6 +158,7 @@ class _GridPageState extends State<GridPage> {
child: BlocConsumer<GridBloc, GridState>(
listener: listener,
builder: (context, state) => state.loadingState.map(
idle: (_) => const SizedBox.shrink(),
loading: (_) => const Center(
child: CircularProgressIndicator.adaptive(),
),
@@ -155,25 +167,18 @@ class _GridPageState extends State<GridPage> {
child: GridPageContent(
key: ValueKey(widget.view.id),
view: widget.view,
shrinkWrap: widget.shrinkWrap,
),
),
(err) => Center(
child: AppFlowyErrorPage(
error: err,
),
),
(err) => Center(child: AppFlowyErrorPage(error: err)),
),
idle: (_) => const SizedBox.shrink(),
),
),
),
);
}
void _openRow(
BuildContext context,
String rowId,
) {
void _openRow(BuildContext context, String rowId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final gridBloc = context.read<GridBloc>();
final rowCache = gridBloc.rowCache;
@@ -249,9 +254,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();
@@ -279,13 +286,13 @@ class _GridPageContentState extends State<GridPageContent> {
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_GridHeader(
headerScrollController: headerScrollController,
),
_GridHeader(headerScrollController: headerScrollController),
_GridRows(
viewId: widget.view.id,
scrollController: _scrollController,
shrinkWrap: widget.shrinkWrap,
),
],
);
@@ -300,12 +307,10 @@ class _GridHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<GridBloc, GridState>(
builder: (context, state) {
return GridHeaderSliverAdaptor(
viewId: state.viewId,
anchorScrollController: headerScrollController,
);
},
builder: (_, state) => GridHeaderSliverAdaptor(
viewId: state.viewId,
anchorScrollController: headerScrollController,
),
);
}
}
@@ -314,11 +319,17 @@ class _GridRows extends StatefulWidget {
const _GridRows({
required this.viewId,
required this.scrollController,
this.shrinkWrap = false,
});
final String viewId;
final GridScrollController scrollController;
/// When [shrinkWrap] is active, the Grid will show items according to
/// GridState.visibleRows and will not have a vertical scroll area.
///
final bool shrinkWrap;
@override
State<_GridRows> createState() => _GridRowsState();
}
@@ -330,8 +341,10 @@ class _GridRowsState extends State<_GridRows> {
@override
void initState() {
super.initState();
_evaluateFloatingCalculations();
widget.scrollController.verticalController.addListener(_onScrollChanged);
if (!widget.shrinkWrap) {
_evaluateFloatingCalculations();
widget.scrollController.verticalController.addListener(_onScrollChanged);
}
}
void _onScrollChanged() {
@@ -345,13 +358,16 @@ class _GridRowsState extends State<_GridRows> {
@override
void dispose() {
widget.scrollController.verticalController.removeListener(_onScrollChanged);
if (!widget.shrinkWrap) {
widget.scrollController.verticalController
.removeListener(_onScrollChanged);
}
super.dispose();
}
void _evaluateFloatingCalculations() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (mounted && !widget.shrinkWrap) {
setState(() {
final verticalController = widget.scrollController.verticalController;
// maxScrollExtent is 0.0 if scrolling is not possible
@@ -367,121 +383,64 @@ class _GridRowsState extends State<_GridRows> {
@override
Widget build(BuildContext context) {
return BlocBuilder<GridBloc, GridState>(
buildWhen: (previous, current) => previous.fields != current.fields,
builder: (context, state) {
return Flexible(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints layoutConstraits) {
return _WrapScrollView(
scrollController: widget.scrollController,
contentWidth: GridLayout.headerWidth(
context
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding,
state.fields,
),
child: BlocConsumer<GridBloc, GridState>(
listenWhen: (previous, current) =>
previous.rowCount != current.rowCount,
listener: (context, state) => _evaluateFloatingCalculations(),
builder: (context, state) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
),
child: _renderList(context, state, layoutConstraits),
);
},
),
);
},
),
);
},
);
}
Widget _renderList(
BuildContext context,
GridState state,
BoxConstraints layoutConstraints,
) {
// 1. GridRowBottomBar
// 2. GridCalculationsRow
final itemCount =
state.rowInfos.length + (showFloatingCalculations ? 1 : 2);
return Column(
children: [
Expanded(
child: ReorderableListView.builder(
/// This is a workaround related to
/// https://github.com/flutter/flutter/issues/25652
cacheExtent: max(layoutConstraints.maxHeight, 500),
scrollController: widget.scrollController.verticalController,
physics: const ClampingScrollPhysics(),
buildDefaultDragHandles: false,
proxyDecorator: (child, _, __) => Provider.value(
value: context.read<DatabasePluginWidgetBuilderSize>(),
child: Material(
color: Colors.white.withOpacity(.1),
child: Opacity(opacity: .5, child: child),
Widget child;
if (widget.shrinkWrap) {
child = Scrollbar(
controller: widget.scrollController.horizontalController,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: widget.scrollController.horizontalController,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: GridLayout.headerWidth(
context
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding,
context.read<GridBloc>().state.fields,
),
),
onReorder: (fromIndex, newIndex) {
void moveRow() {
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
if (fromIndex != toIndex) {
context
.read<GridBloc>()
.add(GridEvent.moveRow(fromIndex, toIndex));
}
}
if (state.sorts.isNotEmpty) {
showCancelAndDeleteDialog(
context: context,
title: LocaleKeys.grid_sort_sortsActive.tr(
namedArgs: {
'intention':
LocaleKeys.grid_row_reorderRowDescription.tr(),
},
),
description: LocaleKeys.grid_sort_removeSorting.tr(),
confirmLabel: LocaleKeys.button_remove.tr(),
closeOnAction: true,
onDelete: () {
SortBackendService(viewId: widget.viewId).deleteAllSorts();
moveRow();
},
);
} else {
moveRow();
}
},
itemCount: itemCount,
itemBuilder: (context, index) {
if (index == state.rowInfos.length) {
return const GridRowBottomBar(key: Key('grid_footer'));
}
if (index == state.rowInfos.length + 1 &&
!showFloatingCalculations) {
return GridCalculationsRow(
key: const Key('grid_calculations'),
viewId: widget.viewId,
);
}
return _renderRow(
context,
state.rowInfos[index].rowId,
index: index,
);
},
child: _renderList(context),
),
),
if (showFloatingCalculations) ...[
);
} else {
child = _WrapScrollView(
scrollController: widget.scrollController,
contentWidth: GridLayout.headerWidth(
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
context.read<GridBloc>().state.fields,
),
child: BlocListener<GridBloc, GridState>(
listenWhen: (previous, current) =>
previous.rowCount != current.rowCount,
listener: (context, state) => _evaluateFloatingCalculations(),
child: ScrollConfiguration(
behavior:
ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: _renderList(context),
),
),
);
}
if (widget.shrinkWrap) {
return child;
}
return Flexible(child: child);
}
Widget _renderList(BuildContext context) {
final state = context.read<GridBloc>().state;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.shrinkWrap
? _reorderableListView(state)
: Expanded(child: _reorderableListView(state)),
if (showFloatingCalculations && !widget.shrinkWrap) ...[
_PositionedCalculationsRow(
viewId: widget.viewId,
isAtBottom: isAtBottom,
@@ -491,6 +450,77 @@ class _GridRowsState extends State<_GridRows> {
);
}
Widget _reorderableListView(GridState state) {
final List<Widget> footer = [
const GridRowBottomBar(),
if (widget.shrinkWrap && state.visibleRows < state.rowInfos.length)
const GridRowLoadMoreButton(),
if (!showFloatingCalculations) GridCalculationsRow(viewId: widget.viewId),
];
// If we are using shrinkWrap, we need to show at most
// state.visibleRows + 1 items. The visibleRows can be larger
// than the actual rowInfos length.
final itemCount = widget.shrinkWrap
? (state.visibleRows + 1).clamp(0, state.rowInfos.length + 1)
: state.rowInfos.length + 1;
return ReorderableListView.builder(
cacheExtent: 500,
scrollController: widget.scrollController.verticalController,
physics: const ClampingScrollPhysics(),
buildDefaultDragHandles: false,
shrinkWrap: widget.shrinkWrap,
proxyDecorator: (child, _, __) => Provider.value(
value: context.read<DatabasePluginWidgetBuilderSize>(),
child: Material(
color: Colors.white.withOpacity(.1),
child: Opacity(opacity: .5, child: child),
),
),
onReorder: (fromIndex, newIndex) {
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
if (state.sorts.isNotEmpty) {
showCancelAndDeleteDialog(
context: context,
title: LocaleKeys.grid_sort_sortsActive.tr(
namedArgs: {
'intention': LocaleKeys.grid_row_reorderRowDescription.tr(),
},
),
description: LocaleKeys.grid_sort_removeSorting.tr(),
confirmLabel: LocaleKeys.button_remove.tr(),
closeOnAction: true,
onDelete: () {
SortBackendService(viewId: widget.viewId).deleteAllSorts();
moveRow(fromIndex, toIndex);
},
);
} else {
moveRow(fromIndex, toIndex);
}
},
itemCount: itemCount,
itemBuilder: (context, index) {
if (index == itemCount - 1) {
return Column(
key: const Key('grid_footer'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: footer,
);
}
return _renderRow(
context,
state.rowInfos[index].rowId,
index: index,
);
},
);
}
Widget _renderRow(
BuildContext context,
RowId rowId, {
@@ -509,6 +539,7 @@ class _GridRowsState extends State<_GridRows> {
final child = GridRow(
key: ValueKey("grid_row_$rowId"),
shrinkWrap: widget.shrinkWrap,
fieldController: databaseController.fieldController,
rowId: rowId,
viewId: viewId,
@@ -523,20 +554,22 @@ class _GridRowsState extends State<_GridRows> {
context: rowDetailContext,
builder: (_) {
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
return rowMeta == null
? const SizedBox.shrink()
: BlocProvider.value(
value: context.read<ViewBloc>(),
child: RowDetailPage(
rowController: RowController(
viewId: viewId,
rowMeta: rowMeta,
rowCache: rowCache,
),
databaseController: databaseController,
userProfile: context.read<GridBloc>().userProfile,
),
);
if (rowMeta == null) {
return const SizedBox.shrink();
}
return BlocProvider.value(
value: context.read<ViewBloc>(),
child: RowDetailPage(
rowController: RowController(
viewId: viewId,
rowMeta: rowMeta,
rowCache: rowCache,
),
databaseController: databaseController,
userProfile: context.read<GridBloc>().userProfile,
),
);
},
),
);
@@ -547,6 +580,12 @@ class _GridRowsState extends State<_GridRows> {
return child;
}
void moveRow(int from, int to) {
if (from != to) {
context.read<GridBloc>().add(GridEvent.moveRow(from, to));
}
}
}
class _WrapScrollView extends StatelessWidget {
@@ -57,3 +57,44 @@ class GridRowBottomBar extends StatelessWidget {
);
}
}
class GridRowLoadMoreButton extends StatelessWidget {
const GridRowLoadMoreButton({super.key});
@override
Widget build(BuildContext context) {
final padding =
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding;
final color = Theme.of(context).brightness == Brightness.light
? const Color(0xFF171717).withOpacity(0.4)
: const Color(0xFFFFFFFF).withOpacity(0.4);
return Container(
padding: GridSize.footerContentInsets.copyWith(left: 0) +
EdgeInsets.only(left: padding),
height: GridSize.footerHeight,
child: FlowyButton(
radius: BorderRadius.zero,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: AFThemeExtension.of(context).borderColor),
),
),
text: FlowyText(
lineHeight: 1.0,
LocaleKeys.grid_row_loadMore.tr(),
color: color,
),
margin: const EdgeInsets.symmetric(horizontal: 12),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () => context.read<GridBloc>().add(
const GridEvent.loadMoreRows(),
),
leftIcon: FlowySvg(
FlowySvgs.load_more_s,
color: color,
),
),
);
}
}
@@ -7,7 +7,6 @@ import 'package:appflowy/plugins/database/grid/application/grid_header_bloc.dart
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -154,11 +153,8 @@ class _CellTrailing extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxWidth: GridSize.newPropertyButtonWidth,
minHeight: GridSize.headerHeight,
),
margin: EdgeInsets.only(right: GridSize.scrollBarSize + Insets.m),
width: GridSize.newPropertyButtonWidth,
height: GridSize.headerHeight,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: AFThemeExtension.of(context).borderColor),
@@ -34,6 +34,7 @@ class GridRow extends StatelessWidget {
required this.cellBuilder,
required this.openDetailPage,
required this.index,
this.shrinkWrap = false,
});
final FieldController fieldController;
@@ -43,9 +44,20 @@ class GridRow extends StatelessWidget {
final EditableCellBuilder cellBuilder;
final void Function(BuildContext context) openDetailPage;
final int index;
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
Widget rowContent = RowContent(
fieldController: fieldController,
cellBuilder: cellBuilder,
onExpand: () => openDetailPage(context),
);
if (!shrinkWrap) {
rowContent = Expanded(child: rowContent);
}
return BlocProvider(
create: (_) => RowBloc(
fieldController: fieldController,
@@ -56,17 +68,8 @@ class GridRow extends StatelessWidget {
child: _RowEnterRegion(
child: Row(
children: [
_RowLeading(
viewId: viewId,
index: index,
),
Expanded(
child: RowContent(
fieldController: fieldController,
cellBuilder: cellBuilder,
onExpand: () => openDetailPage(context),
),
),
_RowLeading(viewId: viewId, index: index),
rowContent,
],
),
),
@@ -219,9 +219,7 @@ class TabBarItemButton extends StatelessWidget {
color: color,
),
text: FlowyText(
view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: view.name,
view.nameOrDefault,
lineHeight: 1.0,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
@@ -236,7 +234,7 @@ class TabBarItemButton extends StatelessWidget {
case TabBarViewAction.rename:
NavigatorTextFieldDialog(
title: LocaleKeys.menuAppHeader_renameDialog.tr(),
value: view.name,
value: view.nameOrDefault,
onConfirm: (newValue, _) {
context.read<DatabaseTabBarBloc>().add(
DatabaseTabBarEvent.renameView(view.id, newValue),
@@ -1,6 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@@ -107,9 +105,7 @@ class _DatabaseViewSelectorButton extends StatelessWidget {
const HSpace(6),
Flexible(
child: FlowyText.medium(
tabBar.view.name.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: tabBar.view.name,
tabBar.view.nameOrDefault,
fontSize: 14,
overflow: TextOverflow.ellipsis,
),
Loaded 100 of 551 files, more files were not shown because too many files have changed in this diff. Show more