Compare commits

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

* fix: error displaying in Page style

* fix: error displaying in Favorite/Recent page

* fix: complete the filter logic of icon picker

* fix: the color picker showed when tapping down

* fix: icons are not supported in subpage blocks

* chore: add some tests

* fix: recent icons not working for grid header icon

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

---------

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

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

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

* chore: remove unused code

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

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

* feat: enable auto scroll after inserting column or row

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

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

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

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

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

* test: fix unit test

* fix: downgrade percent indicator

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

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

* chore: upgrade appflowy_editor

* fix: simple table issues on mobile

* feat: highlight cell after insertion

* test: text color and cell background color test

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

* test: add integration test

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

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

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

* test: add integration tests

* chore(flutter_desktop): make the popoovers as barriers

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

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

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

---------

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

* fix: the icon is not supported in sites tab

* feat: keep the icon panel open after click ramdom

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

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

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

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

* fix: support select language of code block

* fix: support hover controls of document

* fix: support slash panel of document

* feat: support image block and file block of document

* feat: support outline block of document

* feat: support add document on document

* feat: support create sub-page on document

* feat: support mention panel

* feat: support more actions

* feat: support create space

* fix: add some shortcuts to document

* fix: add some shortcuts to document

* fix: multiple select blocks

* fix: reupdate trash after restore and delete

* fix: lint check

* fix: callout block

* fix: view icon

* fix: colors

* fix: colors

* feat: support add grid/board/calendar

* feat: support database new layout

* fix: slash panel

* fix: bugs

* fix: grid table

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: bugs

* fix: some bugs

* fix: some bugs

* fix: invite members

* fix: styles

* fix: styles

* fix: styles

* fix: inline code

* fix: image block

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: some bugs

* fix: add applink

* fix: add applink

* fix: add applink

* fix: add share link

* fix: inline link

* fix: add log

* fix: simple table bugs

* fix: simple table bugs

* fix: some bugs

* fix: build size

* fix: build size

* fix: remove private from package.json

* fix: support quick note

* fix: support quick note

* fix: support quick note

* fix: support quick note

* fix: support quick note

* fix: some bugs

* fix: support paste md

* fix: modified toolbar

* fix: modified toolbard actions

* fix: adjust editor toolbar

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

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

* fix: remove cell background color

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

* fix: replace quick action icon color

* fix: add done button in text color menu

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

* fix: table navigation issues and reminder issue

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

* fix: unable to insert page sometimes

* fix: use default name if the title is empty

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

* chore: tooltips for right sidebar actions

* chore: update selection after inserting into existing page

* fix: mobile select sources button background color

* fix: ai response metadata

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

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

* chore: revert podfile changes

* chore: decrease spacing between metadata and hover actions

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

* chore: fixed width numbers
2024-12-27 16:08:27 +08:00
Lucas 7db11c7cfd chore: fix release builder issue (#7058)
* chore: use --locked to install cargo-make

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

* refactor: optimize the code

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

---------

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

* chore: apply suggestions from code review

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

* chore: apply suggestions from code review

* chore: reorganize code

* chore: i18n

* chore: enable opening the document in the sidebar

* fix: async await

* chore: rename ai message action bar widget

* feat: make transactions be reflected in the opened document

* chore: don't forget to close the bloc

* fix: isLastLineEmpty

* chore: code cleanup

* fix: sync after EditorState.apply

* chore: decrease visibility of DocumentBlocMap

* chore: add back missing assert

---------

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

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

* test: support plus menu in table cell on mobile

* feat: add lightImpact feedback

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

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

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

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

* refactor: optimize the code

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

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

---------

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

* chore: remove default slash menu items

* feat: support customizing slash commands in table cell

* fix: unable to delete math equation

* fix: underline issue

* feat: support text align in table

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

* fix: reduce the padding in table cell

* fix: drag column / row error in table

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

* chore: update test

* chore: create events

* chore: load csv

* test: add events

* test: clean

* chore: fix test

* chore: fix test

* chore: fix test

* chore: fmt

---------

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

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

* test: update table align

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

* fix: heading padding

* feat: support reordering table

* feat: highlight cell border when dragging

* chore: optimize the highlight border render logic

* fix: table menu error

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

* test: delete column / delete row

* test: delete column / delete row

* test: enable header row / header column

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

* feat: support row/column action sheet

* feat: highlight reorder button

* feat: support table action mobile

* feat: support insert row / insert column

* feat: add more actions in the table bottom sheet

* feat: implement the action logic for table action menu

* feat: support clear content

* feat: enable header column / header row

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

* fix: table cell parse error

* feat: support insert left/insert right

* feat: add delete in table action menu

* feat: support copy a table row/column

* feat: support copy paste in table cell

* feat: support cut in table

* feat: disable cut if no content in clipboard

* chore: refactor table action bottom sheet

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

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

* feat: add align in table bottom sheet

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

* feat: add align action to table action menu

* feat: integrate bold command in table bottom sheet

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

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

* chore: remove unused code

* fix: page style json parse error

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

* feat: support align in table action menu

* feat: add quick actions in table action menu

* chore: update translations

* feat: integrate copy paste in table action menu

* feat: integrate align in table action menu

* fix: flutter ci

* test: add copy/paste/cut test

* fix: align the table with the document title

* fix: flutter tests

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

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

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

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

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

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

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

* chore: restrict number of selected highest-level documents

* chore: ignore chat views

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

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

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

* chore: merge with main

* chore: remove unneeded code

* fix: sqlite migration

* fix: cargo fmt

* feat: use new api to get the member role

---------

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

* chore: find question id instead of assuming

* chore: fix clippy

* chore: show local messages if they were there

* chore: remove duplicate code

* chore: fix loading message

* chore: revert unintended translation key removal

* chore: update translation for ai service unavailable

* chore: fix initial chat message load

---------

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

* feat: mobile

* chore: disable mention page

* chore: disable select sources when on local ai

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

* chore: bump client api

* chore: fix clippy
2024-12-18 12:12:58 +08:00
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
991 changed files with 50175 additions and 14585 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
+76 -1
View File
@@ -1,4 +1,79 @@
# Release Notes
## Version 0.8.0 - 06/01/2025
### Bug Fixes
Fixed error displaying in the page style menu
Fixed filter logic in the icon picker
Fixed error displaying in the Favorite/Recent page
Fixed the color picker displaying when tapping down
Fixed icons not being supported in subpage blocks
Fixed recent icon functionality in the space icon menu
Fixed "Insert Below" not auto-scrolling the table
Fixed a to-do item with an emoji automatically creating a soft break
Fixed header row/column tap areas being too small
Fixed simple table alignment not working for items that wrap
Fixed web content reverting after removing the inline code format on desktop
Fixed inability to make changes to a row or column in the table when opening a new tab
Fixed changing the language to CKB-KU causing a gray screen on mobile
## Version 0.7.9 - 30/12/2024
### New Features
- Meet AppFlowy Web (Lite): Use AppFlowy directly in your browser.
- Create beautiful documents with 22 content types and markdown support
- Use Quick Note to save anything you want to remember—like meeting notes, a grocery list, or to-dos
- Invite members to your workspace for seamless collaboration
- Create multiple public/private spaces to better organize your content
- Simple Table is now available on Mobile, designed specifically for mobile devices.
- Create and manage Simple Table blocks on Mobile with easy-to-use action menus.
- Use the '+' button in the fixed toolbar to easily add a content block into a table cell on Mobile
- Use '/' to insert a content block into a table cell on Desktop
- Add pages as AI sources in AI chat, enabling you to ask questions about the selected sources
- Add messages to an editable document while chatting with AI side by side
- The new Emoji menu now includes Icons with a Recent section for quickly reusing emojis/icons
- Drag a page from the sidebar into a document to easily mention the page without typing its title
- Paste as plain text, a new option in the right-click paste menu
### Bug Fixes
- Fixed misalignment in numbered lists
- Resolved several bugs in the emoji menu
- Fixed a bug with checklist items
## Version 0.7.8 - 18/12/2024
### New Features
<img width="1068" alt="image" src="https://github.com/user-attachments/assets/cf8bd287-f370-4291-8638-76e2bbf4aaac" />
- Meet Simple Table 2.0:
- Insert a list into a table cell
- Insert images, quotes, callouts, and code blocks into a table cell
- Drag to move rows or columns
- Toggle header rows or columns on/off
- Distribute columns evenly
- Adjust to page width
- Enjoy a new UI/UX for a seamless experience
- Revamped mention page interactions in AI Chat
- Improved AppFlowy AI service
### Bug Fixes
- Fixed an error when opening files in the database in local mode
- Fixed arrow up/down navigation not working for selecting a language in Code Block
- Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work
## 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 +1014,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.8.0"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -53,7 +53,7 @@ android {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.appflowy.appflowy"
minSdkVersion 29
targetSdkVersion 34
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
multiDexEnabled true
@@ -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,20 @@
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
import 'sidebar/sidebar_rename_untitled_test.dart'
as sidebar_rename_untitled_test;
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 +26,6 @@ Future<void> main() async {
// sidebar
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
}
@@ -0,0 +1,62 @@
import 'dart:convert';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_more_popup.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/emoji.dart';
import '../../../shared/util.dart';
void main() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
testWidgets('Change slide bar space icon', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
final emojiIconData = await tester.loadIcon();
final firstIcon = IconsData.fromJson(jsonDecode(emojiIconData.emoji));
await tester.hoverOnWidget(
find.byType(SidebarSpaceHeader),
onHover: () async {
final moreOption = find.byType(SpaceMorePopup);
await tester.tapButton(moreOption);
expect(find.byType(FlowyIconEmojiPicker), findsNothing);
await tester.tapSvgButton(SpaceMoreActionType.changeIcon.leftIconSvg);
expect(find.byType(FlowyIconEmojiPicker), findsOneWidget);
},
);
final icons = find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == firstIcon.iconContent,
);
expect(icons, findsOneWidget);
await tester.tapIcon(EmojiIconData.icon(firstIcon));
final spaceHeader = find.byType(SidebarSpaceHeader);
final spaceIcon = find.descendant(
of: spaceHeader,
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == firstIcon.iconContent,
),
);
expect(spaceIcon, findsOneWidget);
});
}
@@ -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(),
);
});
}
@@ -5,6 +5,7 @@ import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -102,8 +103,7 @@ void main() {
expect(memberCount, findsNWidgets(2));
});
testWidgets('only display one menu item in the workspace menu',
(tester) async {
testWidgets('workspace menu popover behavior test', (tester) async {
// only run the test when the feature flag is on
if (!FeatureFlag.collaborativeWorkspace.isOn) {
return;
@@ -128,6 +128,8 @@ void main() {
final workspaceItem = find.byWidgetPredicate(
(w) => w is WorkspaceMenuItem && w.workspace.name == name,
);
// the workspace menu shouldn't conflict with logout
await tester.hoverOnWidget(
workspaceItem,
onHover: () async {
@@ -136,15 +138,73 @@ void main() {
);
expect(moreButton, findsOneWidget);
await tester.tapButton(moreButton);
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
final logoutButton = find.byType(WorkspaceMoreButton);
await tester.tapButton(logoutButton);
expect(find.text(LocaleKeys.button_logout.tr()), findsOneWidget);
expect(moreButton, findsNothing);
await tester.tapButton(moreButton);
expect(find.text(LocaleKeys.button_logout.tr()), findsNothing);
expect(moreButton, findsOneWidget);
},
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// clicking on the more action button for the same workspace shouldn't do
// anything
await tester.openCollaborativeWorkspaceMenu();
await tester.hoverOnWidget(
workspaceItem,
onHover: () async {
final moreButton = find.byWidgetPredicate(
(w) => w is WorkspaceMoreActionList && w.workspace.name == name,
);
expect(moreButton, findsOneWidget);
await tester.tapButton(moreButton);
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
// click it again
await tester.tapButton(moreButton);
// nothing should happen
expect(
find.text(LocaleKeys.button_rename.tr()),
findsOneWidget,
);
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
},
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// clicking on the more button of another workspace should close the menu
// for this one
await tester.openCollaborativeWorkspaceMenu();
final moreButton = find.byWidgetPredicate(
(w) => w is WorkspaceMoreActionList && w.workspace.name == name,
);
await tester.hoverOnWidget(
workspaceItem,
onHover: () async {
expect(moreButton, findsOneWidget);
await tester.tapButton(moreButton);
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
},
);
final otherWorspaceItem = find.byWidgetPredicate(
(w) => w is WorkspaceMenuItem && w.workspace.name != name,
);
final otherMoreButton = find.byWidgetPredicate(
(w) => w is WorkspaceMoreActionList && w.workspace.name != name,
);
await tester.hoverOnWidget(
otherWorspaceItem,
onHover: () async {
expect(otherMoreButton, findsOneWidget);
await tester.tapButton(otherMoreButton);
expect(find.text(LocaleKeys.button_rename.tr()), findsOneWidget);
expect(moreButton, findsNothing);
},
);
});
@@ -1,4 +1,5 @@
import 'package:appflowy/plugins/database/calendar/presentation/calendar_event_editor.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
@@ -9,7 +10,14 @@ import '../../shared/database_test_op.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('calendar', () {
testWidgets('update calendar layout', (tester) async {
@@ -301,6 +309,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 +341,7 @@ void main() {
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/select/select_option.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -14,7 +14,14 @@ import '../../shared/database_test_op.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('grid edit field test:', () {
testWidgets('rename existing field', (tester) async {
@@ -538,8 +545,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,7 +1,9 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -11,6 +13,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
// Test cases for the Document SubPageBlock that needs to be covered:
@@ -37,7 +40,14 @@ import '../../shared/util.dart';
const _defaultPageName = "";
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('Document SubPageBlock tests', () {
testWidgets('Insert a new SubPageBlock from Slash menu items',
@@ -498,6 +508,38 @@ void main() {
expect(find.text('Parent'), findsNWidgets(2));
});
testWidgets('Displaying icon of subpage', (tester) async {
const firstPage = 'FirstPage';
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: firstPage);
final icon = await tester.loadIcon();
/// create subpage
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_subPage_name.tr(),
offset: 100,
);
/// add icon
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapAddIconButton();
await tester.tapIcon(icon);
await tester.pumpAndSettle();
await tester.openPage(firstPage);
/// check if there is a icon in document
final iconWidget = find.byWidgetPredicate((w) {
if (w is! RawEmojiIconWidget) return false;
final iconData = w.emoji.emoji;
return iconData == icon.emoji;
});
expect(iconWidget, findsOneWidget);
});
});
}
@@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -11,7 +13,14 @@ import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('cover image:', () {
testWidgets('document cover tests', (tester) async {
@@ -147,7 +156,7 @@ void main() {
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
punch,
EmojiIconData.emoji(punch),
);
});
});
@@ -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 {
@@ -67,6 +67,7 @@ void main() {
// open settings page to check the result
await tester.tapButton(settingsButton);
await tester.pumpAndSettle(const Duration(milliseconds: 250));
// check the server type
expect(
@@ -82,7 +83,7 @@ void main() {
// reset to appflowy cloud
await tester.tapButton(
findServerType(AuthenticatorType.appflowyCloudSelfHost),
);
);
// change the server type to appflowy cloud
await tester.tapButton(
findServerType(AuthenticatorType.appflowyCloud),
@@ -1,83 +1,212 @@
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/base.dart';
import '../../shared/common_operations.dart';
import '../../shared/emoji.dart';
import '../../shared/expectation.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final emoji = EmojiIconData.emoji('😁');
const emoji = '😁';
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
group('Icon:', () {
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
tearDownAll(() {
RecentIcons.enable = true;
});
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
testWidgets('Update page emoji in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
});
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// update its emoji
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
testWidgets('Update page emoji in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
});
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its emoji
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: emoji,
);
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
tester.expectViewTitleHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Emoji Search Bar Get Focus', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
await tester.openPage(
value.name,
layout: value,
);
final title = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(value.name),
);
await tester.tapButton(title);
await tester.tapButton(find.byType(EmojiPickerButton));
final emojiPicker = find.byType(FlowyEmojiPicker);
expect(emojiPicker, findsOneWidget);
final textField = find.descendant(
of: emojiPicker,
matching: find.byType(FlowyTextField),
);
expect(textField, findsOneWidget);
final textFieldWidget =
textField.evaluate().first.widget as FlowyTextField;
assert(textFieldWidget.focusNode!.hasFocus);
await tester.tapEmoji(emoji.emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(
value.name,
value,
emoji,
);
}
});
testWidgets('Update page icon in sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await tester.loadIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInSidebarByName(
name: value.name,
parentName: gettingStarted,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await tester.loadIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
}
@@ -1,5 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -11,7 +13,14 @@ import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('Sidebar view item tests', () {
testWidgets('Access view item context menu by right click', (tester) async {
@@ -38,7 +47,11 @@ void main() {
await tester.tapEmoji(emoji);
await tester.pumpAndSettle();
tester.expectViewHasIcon(gettingStarted, ViewLayoutPB.Document, emoji);
tester.expectViewHasIcon(
gettingStarted,
ViewLayoutPB.Document,
EmojiIconData.emoji(emoji),
);
});
});
}
@@ -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();
}
@@ -1,17 +1,30 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
group('document page style:', () {
double getCurrentEditorFontSize() {
@@ -114,5 +127,37 @@ void main() {
);
expect(builtInCover, findsOneWidget);
});
testWidgets('page style icon', (tester) async {
await tester.launchInAnonymousMode();
final createPageButton =
find.byKey(BottomNavigationBarItemType.add.valueKey);
await tester.tapButton(createPageButton);
/// toggle the preset button
await tester.tapSvgButton(FlowySvgs.m_layout_s);
/// select document plugins emoji
final pageStyleIcon = find.byType(PageStyleIcon);
/// there should be none of emoji
final noneText = find.text(LocaleKeys.pageStyle_none.tr());
expect(noneText, findsOneWidget);
await tester.tapButton(pageStyleIcon);
/// select an emoji
const emoji = '😄';
await tester.tapEmoji(emoji);
await tester.tapSvgButton(FlowySvgs.m_layout_s);
expect(noneText, findsNothing);
expect(
find.descendant(
of: pageStyleIcon,
matching: find.text(emoji),
),
findsOneWidget,
);
});
});
}
@@ -0,0 +1,498 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('simple table:', () {
testWidgets('''
1. insert a simple table via + menu
2. insert a row above the table
3. insert a row below the table
4. insert a column left to the table
5. insert a column right to the table
6. delete the first row
7. delete the first column
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// insert left and insert right
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// insert left, insert right
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertLeft.tr(),
),
);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertRight
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(4));
}
// insert above and insert below
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertAbove
.tr(),
),
);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_insertBelow
.tr(),
),
);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(4));
expect(table.columnLength, equals(4));
}
// delete the first row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// delete the first row
await tester.clickRowMenuButton(0);
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(3));
expect(table.columnLength, equals(4));
}
// delete the first column
{
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
await tester.clickColumnMenuButton(0);
await tester.clickSimpleTableQuickAction(SimpleTableMoreAction.delete);
await tester.cancelTableActionMenu();
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.rowLength, equals(3));
expect(table.columnLength, equals(3));
}
});
testWidgets('''
1. insert a simple table via + menu
2. enable header column
3. enable header row
4. set to page width
5. distribute columns evenly
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// enable header column
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_headerColumn
.tr(),
),
);
}
// enable header row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_headerRow.tr(),
),
);
}
// check the table is updated
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.isHeaderColumnEnabled, isTrue);
expect(table.isHeaderRowEnabled, isTrue);
// disable header column
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickColumnMenuButton(0);
final toggleButton = find.descendant(
of: find.byType(SimpleTableHeaderActionButton),
matching: find.byType(CupertinoSwitch),
);
await tester.tapButton(toggleButton);
}
// enable header row
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
final toggleButton = find.descendant(
of: find.byType(SimpleTableHeaderActionButton),
matching: find.byType(CupertinoSwitch),
);
await tester.tapButton(toggleButton);
}
// check the table is updated
expect(table.isHeaderColumnEnabled, isFalse);
expect(table.isHeaderRowEnabled, isFalse);
// set to page width
{
final table = editorState.getNodeAtPath([0])!;
final beforeWidth = table.width;
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the row menu button
await tester.clickRowMenuButton(0);
// enable header column
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_setToPageWidth
.tr(),
),
);
// check the table is updated
expect(table.width, greaterThan(beforeWidth));
}
// distribute columns evenly
{
final table = editorState.getNodeAtPath([0])!;
final beforeWidth = table.width;
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the column menu button
await tester.clickColumnMenuButton(0);
// distribute columns evenly
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys
.document_plugins_simpleTable_moreActions_distributeColumnsWidth
.tr(),
),
);
// check the table is updated
expect(table.width, equals(beforeWidth));
}
});
testWidgets('''
1. insert a simple table via + menu
2. bold
3. clear content
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
await tester.ime.insertText('Hello');
// enable bold
{
// click the column menu button
await tester.clickColumnMenuButton(0);
// enable bold
await tester.clickSimpleTableBoldContentAction();
await tester.cancelTableActionMenu();
// check the first cell is bold
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
expect(paragraph.isInBoldColumn, isTrue);
}
// clear content
{
// focus on the first cell
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: firstParagraphPath)),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// click the column menu button
await tester.clickColumnMenuButton(0);
final clearContents = find.findTextInFlowyText(
LocaleKeys.document_plugins_simpleTable_moreActions_clearContents
.tr(),
);
// clear content
final scrollable = find.descendant(
of: find.byType(SimpleTableBottomSheet),
matching: find.byType(Scrollable),
);
await tester.scrollUntilVisible(
clearContents,
100,
scrollable: scrollable,
);
await tester.tapButton(clearContents);
await tester.cancelTableActionMenu();
// check the first cell is empty
final paragraph = editorState.getNodeAtPath(firstParagraphPath)!;
expect(paragraph.delta!, isEmpty);
}
});
testWidgets('''
1. insert a simple table via + menu
2. insert a heading block in table cell
''', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('simple table');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
final firstParagraphPath = [0, 0, 0, 0];
// open the plus menu and select the table block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_table.tr(),
);
// check the block is inserted
final table = editorState.getNodeAtPath([0])!;
expect(table.type, equals(SimpleTableBlockKeys.type));
expect(table.rowLength, equals(2));
expect(table.columnLength, equals(2));
// focus on the first cell
final selection = editorState.selection!;
expect(selection.isCollapsed, isTrue);
expect(selection.start.path, equals(firstParagraphPath));
}
// open the plus menu and select the heading block
{
await tester.openPlusMenuAndClickButton(
LocaleKeys.editor_heading1.tr(),
);
// check the heading block is inserted
final heading = editorState.getNodeAtPath([0, 0, 0, 0])!;
expect(heading.type, equals(HeadingBlockKeys.type));
expect(heading.level, equals(1));
}
});
});
}
@@ -175,6 +175,33 @@ extension AppFlowyTestBase on WidgetTester {
}
}
Future<void> tapDown(
Finder finder, {
int? pointer,
int buttons = kPrimaryButton,
PointerDeviceKind kind = PointerDeviceKind.touch,
bool pumpAndSettle = true,
int milliseconds = 500,
}) async {
final location = getCenter(finder);
final TestGesture gesture = await startGesture(
location,
pointer: pointer,
buttons: buttons,
kind: kind,
);
await gesture.cancel();
await gesture.down(location);
await gesture.cancel();
if (pumpAndSettle) {
await this.pumpAndSettle(
Duration(milliseconds: milliseconds),
EnginePhase.sendSemanticsUpdate,
const Duration(seconds: 15),
);
}
}
Future<void> tapButtonWithName(
String tr, {
int milliseconds = 500,
@@ -8,8 +8,12 @@ import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table_widgets/_simple_table_bottom_sheet_actions.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
@@ -20,6 +24,7 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
@@ -449,11 +454,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 +613,7 @@ extension CommonOperations on WidgetTester {
required String name,
required String parentName,
required ViewLayoutPB layout,
required String icon,
required EmojiIconData icon,
}) async {
final iconButton = find.descendant(
of: findPageName(
@@ -623,7 +625,11 @@ extension CommonOperations on WidgetTester {
find.byTooltip(LocaleKeys.document_plugins_cover_changeIcon.tr()),
);
await tapButton(iconButton);
await tapEmoji(icon);
if (icon.type == FlowyIconType.emoji) {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
}
await pumpAndSettle();
}
@@ -631,7 +637,7 @@ extension CommonOperations on WidgetTester {
Future<void> updatePageIconInTitleBarByName({
required String name,
required ViewLayoutPB layout,
required String icon,
required EmojiIconData icon,
}) async {
await openPage(
name,
@@ -643,7 +649,11 @@ extension CommonOperations on WidgetTester {
);
await tapButton(title);
await tapButton(find.byType(EmojiPickerButton));
await tapEmoji(icon);
if (icon.type == FlowyIconType.emoji) {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
}
await pumpAndSettle();
}
@@ -841,6 +851,71 @@ extension CommonOperations on WidgetTester {
await tapButton(toggleHeading1);
await pumpUntilNotFound(addMenuItem);
}
/// Click the column menu button in the simple table
Future<void> clickColumnMenuButton(int index) async {
final columnMenuButton = find.byWidgetPredicate(
(w) =>
w is SimpleTableMobileReorderButton &&
w.index == index &&
w.type == SimpleTableMoreActionType.column,
);
await tapButton(columnMenuButton);
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
}
/// Click the row menu button in the simple table
Future<void> clickRowMenuButton(int index) async {
final rowMenuButton = find.byWidgetPredicate(
(w) =>
w is SimpleTableMobileReorderButton &&
w.index == index &&
w.type == SimpleTableMoreActionType.row,
);
await tapButton(rowMenuButton);
await pumpUntilFound(find.byType(SimpleTableCellBottomSheet));
}
/// Click the SimpleTableQuickAction
Future<void> clickSimpleTableQuickAction(SimpleTableMoreAction action) async {
final button = find.byWidgetPredicate(
(widget) => widget is SimpleTableQuickAction && widget.type == action,
);
await tapButton(button);
}
/// Click the SimpleTableContentAction
Future<void> clickSimpleTableBoldContentAction() async {
final button = find.byType(SimpleTableContentBoldAction);
await tapButton(button);
}
/// Cancel the table action menu
Future<void> cancelTableActionMenu() async {
final finder = find.byType(SimpleTableCellBottomSheet);
if (finder.evaluate().isEmpty) {
return;
}
await tapAt(Offset.zero);
await pumpUntilNotFound(finder);
}
/// load icon list and return the first one
Future<EmojiIconData> loadIcon() async {
await loadIconGroups();
final groups = kIconGroups!;
final firstGroup = groups.first;
final firstIcon = firstGroup.icons.first;
return EmojiIconData.icon(
IconsData(
firstGroup.name,
firstIcon.content,
firstIcon.name,
builtInSpaceColors.first,
),
);
}
}
extension SettingsFinder on CommonFinders {
@@ -567,6 +567,12 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(phantom is PhantomChecklistItem, true);
}
void assertPhantomChecklistItemContent(String content) {
final phantom = find.byType(PhantomChecklistItem);
final text = find.text(content);
expect(find.descendant(of: phantom, matching: text), findsOneWidget);
}
Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid();
@@ -1,3 +1,12 @@
import 'dart:convert';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -11,4 +20,48 @@ extension EmojiTestExtension on WidgetTester {
);
await tapButton(emojiWidget);
}
Future<void> tapIcon(EmojiIconData icon) async {
final iconsData = IconsData.fromJson(jsonDecode(icon.emoji));
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.icon.tr),
);
expect(iconTab, findsOneWidget);
await tapButton(iconTab);
final selectedSvg = find.descendant(
of: find.byType(FlowyIconPicker),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(find.byType(IconColorPicker), findsNothing);
/// test for tapping down, it should not display the ColorPicker unless tapping up
await tapDown(selectedSvg);
expect(find.byType(IconColorPicker), findsNothing);
await tapButton(selectedSvg);
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) {
return true;
}
}
}
return false;
}),
);
await tapButton(selectedColor);
}
}
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
@@ -5,6 +7,8 @@ import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
@@ -14,6 +18,7 @@ import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -117,7 +122,7 @@ extension Expectation on WidgetTester {
return;
}
final iconWidget = find.byWidgetPredicate(
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
(widget) => widget is EmojiIconWidget && widget.emoji.emoji == emoji,
);
expect(iconWidget, findsOneWidget);
}
@@ -223,24 +228,52 @@ extension Expectation on WidgetTester {
);
}
void expectViewHasIcon(String name, ViewLayoutPB layout, String emoji) {
void expectViewHasIcon(String name, ViewLayoutPB layout, EmojiIconData data) {
final pageName = findPageName(
name,
layout: layout,
);
final icon = find.descendant(
of: pageName,
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
final type = data.type;
if (type == FlowyIconType.emoji) {
final icon = find.descendant(
of: pageName,
matching: find.text(data.emoji),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.icon) {
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
final icon = find.descendant(
of: pageName,
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(icon, findsOneWidget);
}
}
void expectViewTitleHasIcon(String name, ViewLayoutPB layout, String emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(emoji),
);
expect(icon, findsOneWidget);
void expectViewTitleHasIcon(
String name,
ViewLayoutPB layout,
EmojiIconData data,
) {
final type = data.type;
if (type == FlowyIconType.emoji) {
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(data.emoji),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.icon) {
final iconsData = IconsData.fromJson(jsonDecode(data.emoji));
final icon = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.byWidgetPredicate(
(w) => w is FlowySvg && w.svgString == iconsData.iconContent,
),
);
expect(icon, findsOneWidget);
}
}
void expectSelectedReminder(ReminderOption option) {
+9 -3
View File
@@ -88,6 +88,8 @@ PODS:
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -112,6 +114,7 @@ DEPENDENCIES:
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
SPEC REPOS:
trunk:
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
SPEC CHECKSUMS:
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
@@ -185,21 +190,22 @@ SPEC CHECKSUMS:
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2
@@ -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 && icon.value.isNotEmpty) ...[
RawEmojiIconWidget(
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(),
];
}
}
@@ -57,6 +57,7 @@ Future<T?> showMobileBottomSheet<T>(
double maxChildSize = 0.8,
double initialChildSize = 0.51,
double bottomSheetPadding = 0,
bool enablePadding = true,
}) async {
assert(
showHeader ||
@@ -172,14 +173,18 @@ Future<T?> showMobileBottomSheet<T>(
}
// ----- content area -----
// add content padding and extra bottom padding
children.add(
Padding(
padding:
padding + EdgeInsets.only(bottom: context.bottomSheetPadding()),
child: child,
),
);
if (enablePadding) {
// add content padding and extra bottom padding
children.add(
Padding(
padding:
padding + EdgeInsets.only(bottom: context.bottomSheetPadding()),
child: child,
),
);
} else {
children.add(child);
}
// ----- content area -----
if (children.length == 1) {
@@ -211,14 +216,21 @@ class BottomSheetHeader extends StatelessWidget {
required this.showDoneButton,
this.onRemove,
this.onDone,
this.onBack,
this.onClose,
});
final String title;
final bool showBackButton;
final bool showCloseButton;
final bool showRemoveButton;
final String title;
final bool showDoneButton;
final VoidCallback? onRemove;
final VoidCallback? onBack;
final VoidCallback? onClose;
final void Function(BuildContext context)? onDone;
@override
@@ -230,14 +242,18 @@ class BottomSheetHeader extends StatelessWidget {
child: Stack(
children: [
if (showBackButton)
const Align(
Align(
alignment: Alignment.centerLeft,
child: BottomSheetBackButton(),
child: BottomSheetBackButton(
onTap: onBack,
),
),
if (showCloseButton)
const Align(
Align(
alignment: Alignment.centerLeft,
child: BottomSheetCloseButton(),
child: BottomSheetCloseButton(
onTap: onClose,
),
),
if (showRemoveButton)
Align(
@@ -5,6 +5,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker_screen.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/built_in_svgs.dart';
import 'package:appflowy/workspace/application/user/settings_user_bloc.dart';
@@ -228,12 +229,13 @@ class _UserIcon extends StatelessWidget {
fontSize: 26,
),
onTap: () async {
final icon = await context.push<EmojiPickerResult>(
final icon = await context.push<EmojiIconData>(
Uri(
path: MobileEmojiPickerScreen.routeName,
queryParameters: {
MobileEmojiPickerScreen.pageTitle:
LocaleKeys.titleBar_userIcon.tr(),
MobileEmojiPickerScreen.selectTabs: [PickerTabType.emoji.name],
},
).toString(),
);
@@ -111,7 +111,7 @@ class MobileRecentView extends StatelessWidget {
padding: const EdgeInsets.only(left: 8.0),
child: state.icon.isNotEmpty
? EmojiText(
emoji: state.icon,
emoji: state.icon.emoji,
fontSize: 30.0,
)
: SizedBox.square(
@@ -7,6 +7,7 @@ import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.
import 'package:appflowy/mobile/application/recent/recent_view_bloc.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
@@ -26,7 +27,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import 'package:time/time.dart';
@@ -118,7 +118,7 @@ class MobileViewPage extends StatelessWidget {
}
Widget _buildNameAndLastViewed(BuildContext context, RecentViewState state) {
final supportAvatar = isURL(state.icon);
final supportAvatar = isURL(state.icon.emoji);
if (!supportAvatar) {
return _buildLastViewed(context);
}
@@ -173,23 +173,23 @@ class MobileViewPage extends StatelessWidget {
Widget _buildTitle(BuildContext context, RecentViewState state) {
final name = state.name;
final icon = state.icon;
final fontFamily = Platform.isAndroid || Platform.isLinux
? GoogleFonts.notoColorEmoji().fontFamily
: null;
return RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: icon,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 17.0,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
if (icon.isNotEmpty) ...[
WidgetSpan(
child: SizedBox(
width: 20,
child: EmojiIconWidget(
emoji: icon,
emojiSize: 18.0,
),
),
if (icon.isNotEmpty) const WidgetSpan(child: HSpace(2.0)),
),
),
const WidgetSpan(child: HSpace(8.0)),
],
TextSpan(
text: name,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
@@ -14,6 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
const _kNotificationIconHeight = 36.0;
@@ -264,7 +265,11 @@ class NotificationDocumentContent extends StatelessWidget {
styleCustomizer: styleCustomizer,
// the editor is not editable in the chat
editable: false,
customHeadingPadding: EdgeInsets.zero,
customHeadingPadding: UniversalPlatform.isDesktop
? EdgeInsets.zero
: EdgeInsets.symmetric(
vertical: EditorStyleCustomizer.nodeHorizontalPadding,
),
);
return IgnorePointer(
@@ -2,6 +2,8 @@ import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -121,6 +123,7 @@ class InnerMobileViewItem extends StatelessWidget {
final bool isDraggable;
final bool isExpanded;
final bool isFirstChild;
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
@@ -230,6 +233,7 @@ class SingleMobileInnerViewItem extends StatefulWidget {
final ViewPB view;
final ViewPB? parentView;
final bool isExpanded;
// identify if the view item is rendered as feedback widget inside DraggableItem
final bool isFeedback;
@@ -297,11 +301,9 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
Widget _buildViewIcon() {
final icon = widget.view.icon.value.isNotEmpty
? FlowyText.emoji(
widget.view.icon.value,
fontSize: Platform.isAndroid ? 16.0 : 18.0,
figmaLineHeight: 20.0,
optimizeEmojiAlign: true,
? EmojiIconWidget(
emoji: widget.view.icon.toEmojiIconData(),
emojiSize: Platform.isAndroid ? 16.0 : 18.0,
)
: Opacity(
opacity: 0.7,
@@ -34,7 +34,7 @@ class MobileQuickActionButton extends StatelessWidget {
enable ? null : const WidgetStatePropertyAll(Colors.transparent),
splashColor: Colors.transparent,
child: Container(
height: 52,
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
@@ -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,21 +33,17 @@ 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 &&
chatState.pluginState.state == RunningStatePB.Running;
final aiType = chatState.pluginState.state == RunningStatePB.Running
? AIType.localAI
: AIType.appflowyAI;
emit(
state.copyWith(
aiType: aiType,
supportChatWithFile: supportChatWithFile,
chatState: chatState,
),
@@ -80,19 +65,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 +129,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,33 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
regenerateAnswer: (id) {
_clearRelatedQuestions();
_regenerateAnswer(id);
lastSentMessage = null;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
didReceiveChatSettings: (settings) {
emit(
state.copyWith(selectedSourceIds: settings.ragIds),
);
},
updateSelectedSources: (selectedSourcesIds) async {
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
ragIds: selectedSourcesIds,
);
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
);
},
);
@@ -247,15 +272,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId;
temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => answerStreamMessageId,
);
answerStreamMessageId = '';
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => questionStreamMessageId,
);
questionStreamMessageId = '';
}
@@ -318,12 +347,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
void _init() async {
final payload = LoadNextChatMessagePB(
void _loadSetting() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().fold(
(settings) {
if (!isClosed) {
add(ChatEvent.didReceiveChatSettings(settings: settings));
}
},
Log.error,
);
}
void _loadMessages() async {
final loadMessagesPayload = LoadNextChatMessagePB(
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(payload).send().fold(
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
@@ -384,12 +426,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
answerStream!,
question.messageId,
stream: answerStream!,
questionMessageId: question.messageId,
fakeQuestionMessageId: questionStreamMessage.id,
);
add(ChatEvent.finishSending(question));
add(ChatEvent.startAnswerStreaming(streamAnswer));
lastSentMessage = question;
add(const ChatEvent.finishSending());
add(ChatEvent.receiveMessage(streamAnswer));
}
},
(err) {
@@ -416,11 +460,50 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
Message _createAnswerStreamMessage(
AnswerStream stream,
Int64 questionMessageId,
) {
answerStreamMessageId = (questionMessageId + 1).toString();
void _regenerateAnswer(String answerMessageIdString) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
answerMessageIdString;
final answerMessageId = Int64.tryParseInt(id);
if (answerMessageId == null) {
return;
}
await answerStream?.dispose();
answerStream = AnswerStream();
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
await AIEventRegenerateResponse(payload).send().fold(
(success) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
questionMessageId: answerMessageId - 1,
).copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
);
}
Message _createAnswerStreamMessage({
required AnswerStream stream,
required Int64 questionMessageId,
String? fakeQuestionMessageId,
}) {
answerStreamMessageId = fakeQuestionMessageId == null
? (questionMessageId + 1).toString()
: "${fakeQuestionMessageId}_ans";
return TextMessage(
id: answerStreamMessageId,
@@ -474,22 +557,44 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
);
}
void _clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
}
@freezed
class ChatEvent with _$ChatEvent {
// chat settings
const factory ChatEvent.didReceiveChatSettings({
required ChatSettingsPB settings,
}) = _DidReceiveChatSettings;
const factory ChatEvent.updateSelectedSources({
required List<String> selectedSourcesIds,
}) = _UpdateSelectedSources;
// send message
const factory ChatEvent.sendMessage({
required String message,
Map<String, dynamic>? metadata,
}) = _SendMessage;
const factory ChatEvent.finishSending(ChatMessagePB message) =
_FinishSendMessage;
const factory ChatEvent.finishSending() = _FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// regenerate
const factory ChatEvent.regenerateAnswer(String id) = _RegenerateAnswer;
// streaming answer
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.stopStream() = _StopStream;
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
@@ -514,12 +619,14 @@ class ChatEvent with _$ChatEvent {
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required ChatLoadingState loadingState,
required List<String> selectedSourceIds,
required LoadChatMessageStatus loadingState,
required PromptResponseState promptResponseState,
}) = _ChatState;
factory ChatState.initial() => const ChatState(
loadingState: ChatLoadingState.loading(),
selectedSourceIds: [],
loadingState: LoadChatMessageStatus.loading,
promptResponseState: PromptResponseState.ready,
);
}
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatEditDocumentService {
static Future<ViewPB?> saveMessagesToNewPage(
String chatPageName,
String parentViewId,
List<TextMessage> messages,
) async {
if (messages.isEmpty) {
return null;
}
// Convert messages to markdown and trim the last empty newline.
final completeMessage = messages.map((m) => m.text).join('\n').trimRight();
if (completeMessage.isEmpty) {
return null;
}
final document = customMarkdownToDocument(completeMessage);
final initialBytes =
DocumentDataPBFromTo.fromDocument(document)?.writeToBuffer();
if (initialBytes == null) {
Log.error('Failed to convert messages to document');
return null;
}
return ViewBackendService.createView(
name: LocaleKeys.chat_addToNewPageName.tr(args: [chatPageName]),
layoutType: ViewLayoutPB.Document,
parentViewId: parentViewId,
initialDataBytes: initialBytes,
).toNullable();
}
static Future<void> addMessageToPage(
String documentId,
TextMessage message,
) async {
if (message.text.isEmpty) {
Log.error('Message is empty');
return;
}
final bloc = DocumentBloc(
documentId: documentId,
saveToBlocMap: false,
)..add(const DocumentEvent.initial());
if (bloc.state.editorState == null) {
await bloc.stream.firstWhere((state) => state.editorState != null);
}
final editorState = bloc.state.editorState;
if (editorState == null) {
Log.error("Can't get EditorState of document");
return;
}
final messageDocument = customMarkdownToDocument(message.text);
if (messageDocument.isEmpty) {
Log.error('Failed to convert message to document');
return;
}
final lastNodeOrNull = editorState.document.root.children.lastOrNull;
final rootIsEmpty = lastNodeOrNull == null;
final isLastLineEmpty = lastNodeOrNull?.children.isNotEmpty == false &&
lastNodeOrNull?.delta?.isNotEmpty == false;
final nodes = [
if (rootIsEmpty || !isLastLineEmpty) paragraphNode(),
...messageDocument.root.children,
paragraphNode(),
];
final insertPath = rootIsEmpty ||
listEquals(lastNodeOrNull.path, const [0]) && isLastLineEmpty
? const [0]
: lastNodeOrNull.path.next;
final transaction = editorState.transaction..insertNodes(insertPath, nodes);
await editorState.apply(transaction);
await bloc.close();
}
}
@@ -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,407 @@
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_select_sources_cubit.freezed.dart';
const int _kMaxSelectedParentPageCount = 3;
enum SourceSelectedStatus {
unselected,
selected,
partiallySelected;
bool get isUnselected => this == unselected;
bool get isSelected => this == selected;
bool get isPartiallySelected => this == partiallySelected;
}
class ChatSource {
ChatSource({
required this.view,
required this.parentView,
required this.children,
required bool isExpanded,
required SourceSelectedStatus selectedStatus,
required IgnoreViewType ignoreStatus,
}) : isExpandedNotifier = ValueNotifier(isExpanded),
selectedStatusNotifier = ValueNotifier(selectedStatus),
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
final ViewPB view;
final ViewPB? parentView;
final List<ChatSource> children;
final ValueNotifier<bool> isExpandedNotifier;
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
bool get isExpanded => isExpandedNotifier.value;
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
void toggleIsExpanded() {
isExpandedNotifier.value = !isExpanded;
}
ChatSource copy() {
return ChatSource(
view: view,
parentView: parentView,
children: children.map<ChatSource>((child) => child.copy()).toList(),
ignoreStatus: ignoreStatus,
isExpanded: isExpanded,
selectedStatus: selectedStatus,
);
}
ChatSource? findChildBySourceId(String sourceId) {
if (view.id == sourceId) {
return this;
}
for (final child in children) {
final childResult = child.findChildBySourceId(sourceId);
if (childResult != null) {
return childResult;
}
}
return null;
}
void resetIgnoreViewTypeRecursive() {
ignoreStatusNotifier.value = view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable;
for (final child in children) {
child.resetIgnoreViewTypeRecursive();
}
}
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
ignoreStatusNotifier.value = newIgnoreViewType;
for (final child in children) {
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
}
}
void dispose() {
for (final child in children) {
child.dispose();
}
isExpandedNotifier.dispose();
selectedStatusNotifier.dispose();
ignoreStatusNotifier.dispose();
}
}
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
ChatSettingsCubit() : super(ChatSettingsState.initial());
List<String> selectedSourceIds = [];
List<ChatSource> sources = [];
List<ChatSource> selectedSources = [];
String filter = '';
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds = [...newSelectedSourceIds];
}
void refreshSources(List<ViewPB> spaceViews) async {
filter = "";
final newSources = await Future.wait(
spaceViews.map((view) => _recursiveBuild(view, null)),
);
for (final source in newSources) {
_restrictSelectionIfNecessary(source.children);
}
final selected = newSources
.map((source) => _buildSelectedSources(source))
.flattened
.toList();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: newSources,
),
);
sources
..forEach((e) => e.dispose())
..clear()
..addAll(newSources.map((e) => e.copy()));
selectedSources
..forEach((e) => e.dispose())
..clear()
..addAll(selected.map((e) => e.copy()));
}
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
final isThisSourceSelected = selectedSourceIds.contains(view.id);
final childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
int selectedCount = 0;
final children = <ChatSource>[];
if (childrenViews != null) {
for (final childView in childrenViews) {
if (childView.layout == ViewLayoutPB.Chat) {
continue;
}
final childChatSource = await _recursiveBuild(childView, view);
if (childChatSource.selectedStatus.isSelected) {
selectedCount++;
}
children.add(childChatSource);
}
final areAllChildrenSelectedOrNoChildren =
children.length == selectedCount;
final isAnyChildNotUnselected =
children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
}
} else if (isThisSourceSelected) {
selectedStatus = SourceSelectedStatus.selected;
}
return ChatSource(
view: view,
parentView: parentView,
children: children,
ignoreStatus: view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable,
isExpanded: false,
selectedStatus: selectedStatus,
);
}
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
for (final source in sources) {
source.resetIgnoreViewTypeRecursive();
}
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
_kMaxSelectedParentPageCount) {
sources
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
.forEach(
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
);
}
}
void updateFilter(String filter) {
this.filter = filter;
for (final source in state.visibleSources) {
source.dispose();
}
if (sources.isEmpty) {
emit(ChatSettingsState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
final visible =
sources.map(_buildSearchResults).nonNulls.nonNulls.toList();
emit(
state.copyWith(
selectedSources: selected,
visibleSources: visible,
),
);
}
}
/// traverse tree to build up search query
ChatSource? _buildSearchResults(ChatSource chatSource) {
final isVisible = chatSource.view.nameOrDefault
.toLowerCase()
.contains(filter.toLowerCase());
final childrenResults = <ChatSource>[];
for (final childSource in chatSource.children) {
final childResult = _buildSearchResults(childSource);
if (childResult != null) {
childrenResults.add(childResult);
}
}
return isVisible || childrenResults.isNotEmpty
? ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
children: childrenResults,
ignoreStatus: chatSource.ignoreStatus,
isExpanded: chatSource.isExpanded,
selectedStatus: chatSource.selectedStatus,
)
: null;
}
/// traverse tree to build up selected sources
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
final children = <ChatSource>[];
for (final childSource in chatSource.children) {
children.addAll(_buildSelectedSources(childSource));
}
return selectedSourceIds.contains(chatSource.view.id)
? [
ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
children: children,
ignoreStatus: chatSource.ignoreStatus,
selectedStatus: chatSource.selectedStatus,
isExpanded: true,
),
]
: children;
}
void toggleSelectedStatus(ChatSource chatSource) {
if (chatSource.view.isSpace) {
return;
}
final allIds = _recursiveGetSourceIds(chatSource);
if (chatSource.selectedStatus.isUnselected ||
chatSource.selectedStatus.isPartiallySelected &&
!chatSource.view.layout.isDocumentView) {
for (final id in allIds) {
if (!selectedSourceIds.contains(id)) {
selectedSourceIds.add(id);
}
}
} else {
for (final id in allIds) {
if (selectedSourceIds.contains(id)) {
selectedSourceIds.remove(id);
}
}
}
updateSelectedStatus();
}
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
return [
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
for (final childSource in chatSource.children)
..._recursiveGetSourceIds(childSource),
];
}
void updateSelectedStatus() {
if (sources.isEmpty) {
return;
}
for (final source in sources) {
_recursiveUpdateSelectedStatus(source);
}
_restrictSelectionIfNecessary(sources);
for (final visibleSource in state.visibleSources) {
visibleSource.dispose();
}
final visible = sources.map(_buildSearchResults).nonNulls.toList();
emit(
state.copyWith(
visibleSources: visible,
),
);
}
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
int selectedCount = 0;
for (final childSource in chatSource.children) {
final childStatus = _recursiveUpdateSelectedStatus(childSource);
if (childStatus.isSelected) {
selectedCount++;
}
}
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
final areAllChildrenSelectedOrNoChildren =
chatSource.children.length == selectedCount;
final isAnyChildNotUnselected =
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
}
chatSource.selectedStatusNotifier.value = selectedStatus;
return selectedStatus;
}
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
chatSource.toggleIsExpanded();
if (isSelectedSection) {
for (final selectedSource in selectedSources) {
selectedSource
.findChildBySourceId(chatSource.view.id)
?.toggleIsExpanded();
}
} else {
for (final source in sources) {
final child = source.findChildBySourceId(chatSource.view.id);
if (child != null) {
child.toggleIsExpanded();
break;
}
}
}
}
@override
Future<void> close() {
for (final child in sources) {
child.dispose();
}
for (final child in selectedSources) {
child.dispose();
}
for (final child in state.selectedSources) {
child.dispose();
}
for (final child in state.visibleSources) {
child.dispose();
}
return super.close();
}
}
@freezed
class ChatSettingsState with _$ChatSettingsState {
const factory ChatSettingsState({
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
factory ChatSettingsState.initial() => const ChatSettingsState(
visibleSources: [],
selectedSources: [],
);
}
@@ -1,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,11 +1,11 @@
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';
@@ -14,19 +14,24 @@ 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 +70,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 +80,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 +106,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 +193,13 @@ class _ChatContentPage extends StatelessWidget {
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
return messages.isEmpty ? false : messages.last.id == message.id;
},
builder: (context, isLastMessage) {
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
@@ -272,12 +208,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 +279,81 @@ 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: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
)
: MobileAIPromptInput(
chatId: view.id,
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: text,
metadata: metadata,
),
);
},
isStreaming: !canSendMessage,
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
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);
}
}
}
}
@@ -49,7 +49,7 @@ class ChatEditorStyleCustomizer extends EditorStyleCustomizer {
DefaultAppearanceSettings.getDefaultSelectionColor(context),
defaultTextDirection: appearance.defaultTextDirection,
textStyleConfiguration: TextStyleConfiguration(
lineHeight: 1.4,
lineHeight: 20 / 14,
applyHeightToFirstAscent: true,
applyHeightToLastDescent: true,
text: baseTextStyle(fontFamily).copyWith(
@@ -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(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,497 @@ 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(List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Container(
height: DesktopAIPromptSizes.actionBarHeight,
margin: DesktopAIPromptSizes.actionBarPadding,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
if (state.chatState == null) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _sendButton(),
);
}
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 +582,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 +594,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).colorScheme.surface,
child: PromptInputMobileSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
),
);
if (selectedView != null) {
handler.onSelected(ViewActionPage(view: selectedView));
}
handler.onExit();
_inputFocusNode.requestFocus();
}
}
@@ -0,0 +1,259 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'select_sources_menu.dart';
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
const PromptInputMobileSelectSourcesButton({
super.key,
required this.onUpdateSelectedSources,
});
final void Function(List<String>) onUpdateSelectedSources;
@override
State<PromptInputMobileSelectSourcesButton> createState() =>
_PromptInputMobileSelectSourcesButtonState();
}
class _PromptInputMobileSelectSourcesButtonState
extends State<PromptInputMobileSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
});
}
@override
void dispose() {
cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().userProfile;
final workspaceId = state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
key: ValueKey(workspaceId),
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
child: BlocSelector<SpaceBloc, SpaceState, List<ViewPB>>(
selector: (state) => state.spaces,
builder: (context, spaces) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
context.read<ChatSettingsCubit>().refreshSources(spaces);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
),
);
},
),
);
},
);
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
const _MobileSelectSourcesSheetBody({
required this.scrollController,
});
final ScrollController scrollController;
@override
State<_MobileSelectSourcesSheetBody> createState() =>
_MobileSelectSourcesSheetBodyState();
}
class _MobileSelectSourcesSheetBodyState
extends State<_MobileSelectSourcesSheetBody> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const DragHandle(),
SizedBox(
height: 44.0,
child: Center(
child: FlowyText.medium(
LocaleKeys.chat_selectSources.tr(),
fontSize: 16.0,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (value) => context
.read<ChatSettingsCubit>()
.updateFilter(value),
),
),
),
const Divider(height: 0.5, thickness: 0.5),
],
),
),
),
),
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
final sources = state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: sources.length,
(context, index) {
final source = sources.elementAt(index);
return ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${source.view.id}',
),
chatSource: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 40.0,
);
},
),
);
},
),
],
);
}
}
class _Header extends SliverPersistentHeaderDelegate {
const _Header({
required this.child,
});
final Widget child;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return child;
}
@override
double get maxExtent => 120.5;
@override
double get minExtent => 120.5;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return false;
}
}
@@ -0,0 +1,526 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
import 'chat_mention_page_menu.dart';
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
const PromptInputDesktopSelectSourcesButton({
super.key,
required this.onUpdateSelectedSources,
});
final void Function(List<String>) onUpdateSelectedSources;
@override
State<PromptInputDesktopSelectSourcesButton> createState() =>
_PromptInputDesktopSelectSourcesButtonState();
}
class _PromptInputDesktopSelectSourcesButtonState
extends State<PromptInputDesktopSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
final popoverController = PopoverController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
});
}
@override
void dispose() {
cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
child: BlocSelector<SpaceBloc, SpaceState, List<ViewPB>>(
selector: (state) => state.spaces,
builder: (context, spaces) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
context.read<ChatSettingsCubit>().refreshSources(spaces);
},
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context.read<ChatSettingsCubit>().refreshSources(spaces);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
onTap: () => popoverController.show(),
),
),
);
},
),
);
}
}
class _IndicatorButton extends StatelessWidget {
const _IndicatorButton({
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
),
const HSpace(2.0),
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return FlowyText(
state.selectedSourceIds.length.toString(),
fontSize: 14,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
);
},
),
const HSpace(2.0),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
),
),
),
);
}
}
class _PopoverContent extends StatelessWidget {
const _PopoverContent();
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
),
),
_buildDivider(),
Flexible(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: [
..._buildSelectedSources(context, state),
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: _buildDivider(),
),
..._buildVisibleSources(context, state),
],
),
),
],
);
},
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 8.0,
endIndent: 8.0,
);
}
Iterable<Widget> _buildSelectedSources(
BuildContext context,
ChatSettingsState state,
) {
return state.selectedSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
}
}
class ChatSourceTreeItem extends StatefulWidget {
const ChatSourceTreeItem({
super.key,
required this.chatSource,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
required this.onSelected,
required this.height,
this.showCheckbox = true,
});
final ChatSource chatSource;
/// nested level of the view item
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final void Function(ChatSource chatSource) onSelected;
final double height;
final bool showCheckbox;
@override
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
}
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: widget.height,
child: ChatSourceTreeItemInner(
chatSource: widget.chatSource,
level: widget.level,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
showCheckbox: widget.showCheckbox,
onSelected: widget.onSelected,
),
);
final disabledEnabledChild =
widget.chatSource.ignoreStatus == IgnoreViewType.disable
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.chatSource.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
return ValueListenableBuilder(
valueListenable: widget.chatSource.isExpandedNotifier,
builder: (context, isExpanded, child) {
// filter the child views that should be ignored
final childViews = widget.chatSource.children
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.toList();
if (!isExpanded || childViews.isEmpty) {
return disabledEnabledChild;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
disabledEnabledChild,
...childViews.map(
(childSource) => ChatSourceTreeItem(
key: ValueKey(
'select_sources_tree_item_${childSource.view.id}',
),
chatSource: childSource,
level: widget.level + 1,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
onSelected: widget.onSelected,
height: widget.height,
showCheckbox: widget.showCheckbox,
),
),
],
);
},
);
}
}
class ChatSourceTreeItemInner extends StatelessWidget {
const ChatSourceTreeItemInner({
super.key,
required this.chatSource,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
required this.showCheckbox,
this.onSelected,
});
final ChatSource chatSource;
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final bool showCheckbox;
final void Function(ChatSource)? onSelected;
@override
Widget build(BuildContext context) {
return FlowyHover(
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
style: HoverStyle(
hoverColor: isSelectedSection
? Colors.transparent
: AFThemeExtension.of(context).lightGreyHover,
),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (!isSelectedSection) {
onSelected?.call(chatSource);
}
},
child: Row(
children: [
const HSpace(4.0),
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
// builds the >, ^ or · button
ToggleIsExpandedButton(
chatSource: chatSource,
isSelectedSection: isSelectedSection,
),
const HSpace(2.0),
// checkbox
if (!chatSource.view.isSpace && showCheckbox) ...[
SourceSelectedStatusCheckbox(
chatSource: chatSource,
),
const HSpace(4.0),
],
// icon
MentionViewIcon(
view: chatSource.view,
),
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
chatSource.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
),
),
],
),
),
);
}
}
class ToggleIsExpandedButton extends StatelessWidget {
const ToggleIsExpandedButton({
super.key,
required this.chatSource,
required this.isSelectedSection,
});
final ChatSource chatSource;
final bool isSelectedSection;
@override
Widget build(BuildContext context) {
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
return const _DotIconWidget();
}
if (chatSource.children.isEmpty) {
return const SizedBox.square(dimension: 16.0);
}
return FlowyHover(
child: GestureDetector(
child: ValueListenableBuilder(
valueListenable: chatSource.isExpandedNotifier,
builder: (context, value, _) => FlowySvg(
value
? FlowySvgs.view_item_expand_s
: FlowySvgs.view_item_unexpand_s,
size: const Size.square(16.0),
),
),
onTap: () => context
.read<ChatSettingsCubit>()
.toggleIsExpanded(chatSource, isSelectedSection),
),
);
}
}
class _DotIconWidget extends StatelessWidget {
const _DotIconWidget();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(6.0),
child: Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).iconTheme.color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
class SourceSelectedStatusCheckbox extends StatelessWidget {
const SourceSelectedStatusCheckbox({
super.key,
required this.chatSource,
});
final ChatSource chatSource;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chatSource.selectedStatusNotifier,
builder: (context, selectedStatus, _) => FlowySvg(
switch (selectedStatus) {
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
},
size: const Size.square(18.0),
blendMode: null,
),
);
}
}
@@ -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,511 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import '../chat_input/select_sources_menu.dart';
import '../layout_define.dart';
import 'message_util.dart';
class AIMessageActionBar extends StatelessWidget {
const AIMessageActionBar({
super.key,
required this.message,
required this.showDecoration,
this.onRegenerate,
this.onOverrideVisibility,
});
final Message message;
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final child = SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () =>
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
children: _buildChildren(),
);
return showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.hoverActionBarRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 8,
spreadRadius: 2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
],
),
child: child,
)
: child;
}
List<Widget> _buildChildren() {
return [
CopyButton(
isInHoverBar: showDecoration,
textMessage: message as TextMessage,
),
RegenerateButton(
isInHoverBar: showDecoration,
onTap: () => onRegenerate?.call(),
),
SaveToPageButton(
textMessage: message as TextMessage,
isInHoverBar: showDecoration,
onOverrideVisibility: onOverrideVisibility,
),
];
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.isInHoverBar,
required this.textMessage,
});
final bool isInHoverBar;
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.copy_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
}
}
class RegenerateButton extends StatelessWidget {
const RegenerateButton({
super.key,
required this.isInHoverBar,
required this.onTap,
});
final bool isInHoverBar;
final void Function() onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_regenerate.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_undo_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: onTap,
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
required this.textMessage,
required this.isInHoverBar,
this.onOverrideVisibility,
});
final TextMessage textMessage;
final bool isInHoverBar;
final void Function(bool)? onOverrideVisibility;
@override
State<SaveToPageButton> createState() => _SaveToPageButtonState();
}
class _SaveToPageButtonState extends State<SaveToPageButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider(
create: (context) => ChatSettingsCubit(),
),
],
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
onClose: () {
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources([spaceView]);
}
widget.onOverrideVisibility?.call(false);
},
child: buildButton(context, spaceView),
popupBuilder: (_) => buildPopover(context),
);
},
),
);
}
Widget buildButton(BuildContext context, ViewPB? spaceView) {
return FlowyTooltip(
message: LocaleKeys.chat_addToPageButton.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIConvoSizes.hoverActionBarIconRadius
: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_add_to_page_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final documentId = getOpenedDocumentId();
if (documentId != null) {
await onAddToExistingPage(context, documentId);
await forceReloadAndUpdateSelection(documentId);
} else {
widget.onOverrideVisibility?.call(true);
if (spaceView != null) {
context.read<ChatSettingsCubit>().refreshSources([spaceView]);
}
popoverController.show();
}
},
),
);
}
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: _SaveToPagePopoverContent(
onAddToNewPage: () {
addMessageToNewPage(context);
popoverController.close();
},
onAddToExistingPage: (documentId) async {
popoverController.close();
final view = await onAddToExistingPage(context, documentId);
if (context.mounted) {
openPageFromMessage(context, view);
}
},
),
);
}
Future<ViewPB?> onAddToExistingPage(
BuildContext context,
String documentId,
) async {
await ChatEditDocumentService.addMessageToPage(
documentId,
widget.textMessage,
);
await Future.delayed(const Duration(milliseconds: 500));
final view = await ViewBackendService.getView(documentId).toNullable();
if (context.mounted) {
showSaveMessageSuccessToast(context, view);
}
return view;
}
void addMessageToNewPage(BuildContext context) async {
final chatView = await ViewBackendService.getView(
context.read<ChatAIMessageBloc>().chatId,
).toNullable();
if (chatView != null) {
final newView = await ChatEditDocumentService.saveMessagesToNewPage(
chatView.nameOrDefault,
chatView.parentViewId,
[widget.textMessage],
);
if (context.mounted) {
showSaveMessageSuccessToast(context, newView);
openPageFromMessage(context, newView);
}
}
}
void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
if (view == null) {
return;
}
showToastNotification(
context,
richMessage: TextSpan(
children: [
TextSpan(
text: LocaleKeys.chat_addToNewPageSuccessToast.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFFFFFFF),
),
),
const TextSpan(
text: ' ',
),
TextSpan(
text: view.nameOrDefault,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFFFFFFFF),
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Future<void> forceReloadAndUpdateSelection(String documentId) async {
final bloc = DocumentBloc.findOpen(documentId);
if (bloc == null) {
return;
}
await bloc.forceReloadDocumentState();
await Future.delayed(const Duration(milliseconds: 500));
final editorState = bloc.state.editorState;
final lastNodePath = editorState?.getLastSelectable()?.$1.path;
if (editorState == null || lastNodePath == null) {
return;
}
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: lastNodePath)),
),
);
}
String? getOpenedDocumentId() {
final pageManager = getIt<TabsBloc>().state.currentPageManager;
if (!pageManager.showSecondaryPluginNotifier.value) {
return null;
}
return pageManager.secondaryNotifier.plugin.id;
}
}
class _SaveToPagePopoverContent extends StatelessWidget {
const _SaveToPagePopoverContent({
required this.onAddToNewPage,
required this.onAddToExistingPage,
});
final void Function() onAddToNewPage;
final void Function(String) onAddToExistingPage;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 24,
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: FlowyText(
LocaleKeys.chat_addToPageTitle.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
),
),
_buildDivider(),
Expanded(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
),
),
_buildDivider(),
_addToNewPageButton(context),
],
);
},
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 12.0,
endIndent: 12.0,
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'save_to_page_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: (source) {
if (!source.view.isSpace) {
onAddToExistingPage(source.view.id);
}
},
height: 30.0,
),
);
}
Widget _addToNewPageButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: SizedBox(
height: 30,
child: FlowyButton(
iconPadding: 8,
onTap: onAddToNewPage,
text: FlowyText(
LocaleKeys.chat_addToNewPage.tr(),
figmaLineHeight: 20,
),
leftIcon: FlowySvg(
FlowySvgs.add_m,
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
),
),
);
}
}
@@ -4,20 +4,25 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/chat_mention_page_bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
import 'ai_message_action_bar.dart';
import 'message_util.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
/// the actions will be displayed below the response if the response is the
@@ -30,12 +35,14 @@ class ChatAIMessageBubble extends StatelessWidget {
required this.child,
required this.showActions,
this.isLastMessage = false,
this.onRegenerate,
});
final Message message;
final Widget child;
final bool showActions;
final bool isLastMessage;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -60,6 +67,7 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapBottomActions(Widget child) {
return ChatAIBottomInlineActions(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -67,6 +75,7 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapHover(Widget child) {
return ChatAIMessageHover(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -74,6 +83,7 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget _wrapPopMenu(Widget child) {
return ChatAIMessagePopup(
message: message,
onRegenerate: onRegenerate,
child: child,
);
}
@@ -84,10 +94,12 @@ class ChatAIBottomInlineActions extends StatelessWidget {
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -101,13 +113,10 @@ class ChatAIBottomInlineActions extends StatelessWidget {
start: DesktopAIConvoSizes.avatarSize +
DesktopAIConvoSizes.avatarAndChatBubbleSpacing,
),
child: AIResponseActionBar(
child: AIMessageActionBar(
message: message,
showDecoration: false,
children: [
CopyButton(
textMessage: message as TextMessage,
),
],
onRegenerate: onRegenerate,
),
),
const VSpace(32.0),
@@ -121,10 +130,12 @@ class ChatAIMessageHover extends StatefulWidget {
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@@ -136,6 +147,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
bool hoverBubble = false;
bool hoverActionBar = false;
bool overrideVisibility = false;
ScrollPosition? scrollPosition;
@@ -194,19 +206,20 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
}
},
child: Container(
constraints: const BoxConstraints(
constraints: BoxConstraints(
maxWidth: 784,
maxHeight: 28,
maxHeight: DesktopAIConvoSizes.actionBarIconSize +
DesktopAIConvoSizes.hoverActionBarPadding.vertical,
),
alignment: Alignment.topLeft,
child: hoverBubble || hoverActionBar
? AIResponseActionBar(
child: hoverBubble || hoverActionBar || overrideVisibility
? AIMessageActionBar(
message: widget.message,
showDecoration: true,
children: [
CopyButton(
textMessage: widget.message as TextMessage,
),
],
onRegenerate: widget.onRegenerate,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
)
: null,
),
@@ -255,7 +268,10 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
final messageOffset = messageRenderBox.localToGlobal(Offset.zero);
final messageHeight = messageRenderBox.size.height;
return messageOffset.dy + messageHeight + 28 <=
return messageOffset.dy +
messageHeight +
DesktopAIConvoSizes.actionBarIconSize +
DesktopAIConvoSizes.hoverActionBarPadding.vertical <=
scrollableOffset.dy + scrollableHeight;
}
@@ -266,119 +282,17 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
}
}
class AIResponseActionBar extends StatelessWidget {
const AIResponseActionBar({
super.key,
required this.showDecoration,
required this.children,
});
final List<Widget> children;
final bool showDecoration;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final child = SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () =>
const HSpace(DesktopAIConvoSizes.actionBarIconSpacing),
children: children,
);
return showDecoration
? Container(
padding: const EdgeInsets.all(2.0),
decoration: BoxDecoration(
borderRadius: DesktopAIConvoSizes.actionBarIconRadius,
border: Border.all(
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).dividerColor,
),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: const Offset(0, 1),
blurRadius: 2,
spreadRadius: -2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 8,
spreadRadius: 2,
color: isLightMode
? const Color(0x051F2329)
: Theme.of(context).shadowColor.withOpacity(0.02),
),
],
),
child: child,
)
: child;
}
}
class CopyButton extends StatelessWidget {
const CopyButton({
super.key,
required this.textMessage,
});
final TextMessage textMessage;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopy.tr(),
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
FlowySvgs.copy_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
onPressed: () async {
final document = customMarkdownToDocument(textMessage.text);
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: textMessage.text,
inAppJson: jsonEncode(document.toJson()),
),
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
}
},
),
);
}
}
class ChatAIMessagePopup extends StatelessWidget {
const ChatAIMessagePopup({
super.key,
required this.child,
required this.message,
this.onRegenerate,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
@override
Widget build(BuildContext context) {
@@ -393,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,12 +1,17 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
import 'package:time/time.dart';
class AIMessageMetadata extends StatefulWidget {
@@ -43,6 +48,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(
@@ -68,34 +74,44 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
runSpacing: 4.0,
children: widget.sources.map(
(m) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 240,
),
child: FlowyButton(
margin: const EdgeInsets.all(4.0),
useIntrinsicWidth: true,
radius: BorderRadius.circular(8.0),
text: FlowyText(
m.name,
fontSize: 12,
overflow: TextOverflow.ellipsis,
),
leftIcon: FlowySvg(
FlowySvgs.icon_document_s,
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
disable: m.source != appflowySource,
onTap: () {
if (m.source != appflowySource) {
return;
if (isURL(m.id)) {
return _MetadataButton(
name: m.id,
onTap: () => widget.onSelectedMetadata?.call(m),
);
} else if (isUUID(m.id)) {
return FutureBuilder<ViewPB?>(
future: ViewBackendService.getView(m.id)
.then((f) => f.toNullable()),
builder: (context, snapshot) {
final data = snapshot.data;
if (!snapshot.hasData ||
snapshot.connectionState != ConnectionState.done ||
data == null) {
return _MetadataButton(
name: m.name,
onTap: () => widget.onSelectedMetadata?.call(m),
);
}
widget.onSelectedMetadata?.call(m);
return BlocProvider(
create: (_) => ViewBloc(view: data),
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) {
return _MetadataButton(
name: state.view.nameOrDefault,
onTap: () => widget.onSelectedMetadata?.call(m),
);
},
),
);
},
),
);
);
} else {
return _MetadataButton(
name: m.name,
onTap: () => widget.onSelectedMetadata?.call(m),
);
}
},
).toList(),
),
@@ -105,3 +121,39 @@ class _AIMessageMetadataState extends State<AIMessageMetadata> {
);
}
}
class _MetadataButton extends StatelessWidget {
const _MetadataButton({
this.name = "",
this.onTap,
});
final String name;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 240,
),
child: FlowyButton(
margin: const EdgeInsets.all(4.0),
useIntrinsicWidth: true,
radius: BorderRadius.circular(8.0),
text: FlowyText(
name,
fontSize: 12,
overflow: TextOverflow.ellipsis,
),
leftIcon: FlowySvg(
FlowySvgs.icon_document_s,
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onTap: onTap,
),
);
}
}
@@ -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,36 +62,45 @@ 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,
onSelectedMetadata: onSelectedMetadata,
),
if (state.sources.isNotEmpty && !isLastMessage)
const VSpace(16.0),
const VSpace(8.0),
],
),
);
@@ -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: () {
@@ -58,7 +58,7 @@ class _ChatErrorMessageWidgetState extends State<ChatErrorMessageWidget> {
mainAxisSize: MainAxisSize.min,
children: [
const FlowySvg(
FlowySvgs.warning_filled_s,
FlowySvgs.toast_error_filled_s,
blendMode: null,
),
const HSpace(8.0),
@@ -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,32 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:universal_platform/universal_platform.dart';
/// Opens a message in the right hand sidebar on desktop, and push the page
/// on mobile
void openPageFromMessage(BuildContext context, ViewPB? view) {
if (view == null) {
showToastNotification(
context,
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
type: ToastificationType.error,
);
return;
}
if (UniversalPlatform.isDesktop) {
getIt<TabsBloc>().add(
TabsEvent.openSecondaryPlugin(
plugin: view.plugin(),
),
);
} else {
context.pushView(view);
}
}
@@ -1,6 +1,9 @@
import 'dart:math';
import 'package:appflowy/plugins/base/emoji/emoji_picker_header.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_search_bar.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -8,23 +11,39 @@ import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
// use a global value to store the selected emoji to prevent reloading every time.
EmojiData? kCachedEmojiData;
const _kRecentEmojiCategoryId = 'Recent';
class EmojiPickerResult {
EmojiPickerResult({
required this.emojiId,
required this.emoji,
this.isRandom = false,
});
final String emojiId;
final String emoji;
final bool isRandom;
}
class FlowyEmojiPicker extends StatefulWidget {
const FlowyEmojiPicker({
super.key,
required this.onEmojiSelected,
this.emojiPerLine = 9,
this.ensureFocus = false,
});
final EmojiSelectedCallback onEmojiSelected;
final ValueChanged<EmojiPickerResult> onEmojiSelected;
final int emojiPerLine;
final bool ensureFocus;
@override
State<FlowyEmojiPicker> createState() => _FlowyEmojiPickerState();
}
class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
EmojiData? emojiData;
late EmojiData emojiData;
bool loaded = false;
@override
void initState() {
@@ -32,12 +51,12 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
// load the emoji data from cache if it's available
if (kCachedEmojiData != null) {
emojiData = kCachedEmojiData;
loadEmojis(kCachedEmojiData!);
} else {
EmojiData.builtIn().then(
(value) {
kCachedEmojiData = value;
setState(() => emojiData = value);
loadEmojis(value);
},
);
}
@@ -45,7 +64,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
@override
Widget build(BuildContext context) {
if (emojiData == null) {
if (!loaded) {
return const Center(
child: SizedBox.square(
dimension: 24.0,
@@ -57,21 +76,21 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
}
return EmojiPicker(
emojiData: emojiData!,
emojiData: emojiData,
configuration: EmojiPickerConfiguration(
showTabs: false,
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
perLine: widget.emojiPerLine,
),
onEmojiSelected: widget.onEmojiSelected,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
headerBuilder: (context, category) {
return FlowyEmojiHeader(
category: category,
onEmojiSelected: (id, emoji) {
widget.onEmojiSelected.call(
EmojiPickerResult(emojiId: id, emoji: emoji),
);
RecentIcons.putEmoji(id);
},
padding: const EdgeInsets.symmetric(horizontal: 16.0),
headerBuilder: (_, category) => FlowyEmojiHeader(category: category),
itemBuilder: (context, emojiId, emoji, callback) {
final name = emojiData?.emojis[emojiId]?.name ?? '';
final name = emojiData.emojis[emojiId]?.name ?? '';
return SizedBox.square(
dimension: 36.0,
child: FlowyButton(
@@ -79,6 +98,7 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
radius: Corners.s8Border,
text: FlowyTooltip(
message: name,
preferBelow: false,
child: FlowyText.emoji(
emoji,
fontSize: 24.0,
@@ -92,17 +112,43 @@ class _FlowyEmojiPickerState extends State<FlowyEmojiPicker> {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: FlowyEmojiSearchBar(
emojiData: emojiData!,
emojiData: emojiData,
ensureFocus: widget.ensureFocus,
onKeywordChanged: (value) {
keyword.value = value;
},
onSkinToneChanged: (value) {
skinTone.value = value;
},
onRandomEmojiSelected: widget.onEmojiSelected,
onRandomEmojiSelected: (id, emoji) {
widget.onEmojiSelected.call(
EmojiPickerResult(emojiId: id, emoji: emoji, isRandom: true),
);
RecentIcons.putEmoji(id);
},
),
);
},
);
}
void loadEmojis(EmojiData data) {
RecentIcons.getEmojiIds().then((v) {
if (v.isEmpty) {
emojiData = data;
setState(() => loaded = true);
return;
}
final categories = List.of(data.categories);
categories.insert(
0,
Category(
id: _kRecentEmojiCategoryId,
emojiIds: v.sublist(0, min(widget.emojiPerLine, v.length)),
),
);
emojiData = EmojiData(categories: categories, emojis: data.emojis);
setState(() => loaded = true);
});
}
}
@@ -1,23 +1,44 @@
import 'package:appflowy/plugins/base/icon/icon_picker_page.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MobileEmojiPickerScreen extends StatelessWidget {
const MobileEmojiPickerScreen({super.key, this.title});
import '../../../generated/locale_keys.g.dart';
import '../../../mobile/presentation/base/app_bar/app_bar.dart';
import '../../../shared/icon_emoji_picker/tab.dart';
class MobileEmojiPickerScreen extends StatelessWidget {
const MobileEmojiPickerScreen({
super.key,
this.title,
this.selectedType,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
final PickerTabType? selectedType;
final String? title;
final List<PickerTabType> tabs;
static const routeName = '/emoji_picker';
static const pageTitle = 'title';
static const iconSelectedType = 'iconSelectedType';
static const selectTabs = 'tabs';
@override
Widget build(BuildContext context) {
return IconPickerPage(
title: title,
onSelected: (result) {
context.pop<EmojiPickerResult>(result);
},
return Scaffold(
appBar: FlowyAppBar(
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
),
body: SafeArea(
child: FlowyIconEmojiPicker(
tabs: tabs,
initialType: selectedType,
onSelectedEmoji: (r) {
context.pop<EmojiIconData>(r.data);
},
),
),
);
}
}
@@ -1,28 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class IconPickerPage extends StatelessWidget {
const IconPickerPage({
super.key,
this.title,
required this.onSelected,
});
final void Function(EmojiPickerResult) onSelected;
final String? title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FlowyAppBar(
titleText: title ?? LocaleKeys.titleBar_pageIcon.tr(),
),
body: SafeArea(
child: FlowyIconEmojiPicker(onSelectedEmoji: onSelected),
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:flutter/material.dart';
import '../../../generated/flowy_svgs.g.dart';
class IconWidget extends StatelessWidget {
const IconWidget({
super.key,
required this.size,
required this.data,
});
final IconsData data;
final double size;
@override
Widget build(BuildContext context) {
final colorValue = int.tryParse(data.color ?? '');
Color? color;
if (colorValue != null) {
color = Color(colorValue);
}
return FlowySvg.string(
data.iconContent,
size: Size.square(size),
color: color,
);
}
}
Loaded 100 of 991 files, more files were not shown because too many files have changed in this diff. Show more