Compare commits

...

240 commits
0.8.7 ... main

Author SHA1 Message Date
Nathan.fooo
f72739d98d
Merge pull request #7788 from AppFlowy-IO/local_auth_type
refactor: local auth type
2025-04-20 15:17:14 +08:00
Nathan
fd581b4453 chore: clippy 2025-04-20 14:37:21 +08:00
Nathan
747a63d452 chore: create user workspace for anon user 2025-04-20 14:34:30 +08:00
Nathan
833a2bf5d6 Merge branch 'main' into local_auth_type 2025-04-20 13:36:18 +08:00
Nathan.fooo
607b7ecd1f
Merge pull request #7766 from AppFlowy-IO/private_database_inline_view
chore: remove inline view id reference
2025-04-20 13:35:53 +08:00
Nathan
bb5d36402a chore: fix test 2025-04-20 13:35:06 +08:00
Nathan
ccd1f5f8e9 chore: revert pod lock 2025-04-20 12:47:55 +08:00
Nathan
2c5f41b580 fix: set auth type 2025-04-20 12:47:14 +08:00
Nathan
2f5b494885 chore: remove authticator pb 2025-04-20 00:48:31 +08:00
Nathan.fooo
58f87b39aa
Merge pull request #7785 from AppFlowy-IO/auth_type_per_workspace
refactor: workspace setting and auth type
2025-04-20 00:24:45 +08:00
Nathan
d478ecfd41 chore: remove unused code 2025-04-19 23:33:34 +08:00
Nathan
72fc0cce07 chore: move sql 2025-04-19 22:18:15 +08:00
Nathan
81f63bebe6 chore: sql 2025-04-19 21:03:50 +08:00
Nathan
102087537a chore: fmt 2025-04-19 15:50:42 +08:00
Nathan
6dac45172e chore: auth type 2025-04-19 15:34:06 +08:00
Richard Shiue
84952b9056
chore: adjust generate theme script to import subtle colors (#7784) 2025-04-19 14:24:32 +08:00
Nathan
e851fba71b chore: clippy 2025-04-19 14:21:22 +08:00
Nathan
3a05a4851f chore: clippy 2025-04-19 14:07:49 +08:00
Nathan
edc5710e32 chore: auth type and remove unused code 2025-04-19 14:00:51 +08:00
Richard Shiue
1802792795
Merge pull request #7778 from AppFlowy-IO/feat/custom-prompt
feat: merge develop branch
2025-04-18 21:34:10 +08:00
Richard Shiue
28e89beb43 feat: appflowy theme lerp (#7771)
* feat: implement appflowy theme lerping

* refactor: use theme builder and adjust script

* chore: rename theme data

* chore: add doc comments

* chore: rename function

* chore: don't use theme extension

* chore: use the animated appflowy theme widget

* chore: clean up inherited theme
2025-04-18 21:06:46 +08:00
Richard Shiue
889756ebb0 refactor: use script to generate design tokens (#7751)
* refactor: use script to generate design tokens

* chore: improve code readaility

* refactor: make builder reusable to built in themes

* chore: improve code readability
2025-04-18 21:06:46 +08:00
Richard Shiue
54b5e248e3 feat: implement modal (#7750) 2025-04-18 21:06:46 +08:00
Nathan.fooo
d24383b6ea
Merge pull request #7779 from AppFlowy-IO/server_type
refactor: server type name
2025-04-18 18:55:10 +08:00
Nathan
2dc22004a1 refactor: remove server type 2025-04-18 15:57:33 +08:00
Nathan
0906febe95 refactor: remove server type 2025-04-18 15:48:17 +08:00
Richard Shiue
b12bd8ee85 feat: add medium sized text field (#7737)
* feat: add medium sized text field

* chore: remove height
2025-04-18 15:36:39 +08:00
Richard Shiue
e1bfb7095b feat: improve select modal button (#7736) 2025-04-18 15:35:21 +08:00
Richard Shiue
068f93c258 feat: add shadow tokens (#7726) 2025-04-18 15:35:21 +08:00
Richard Shiue
d8401e09c9 feat: implement keyboard triggering on buttons and add focus state (#7724)
* feat: implement keyboard triggering on buttons and add focus state

* chore: pass isFocused to builders
2025-04-18 15:35:21 +08:00
Nathan
394ac85c32 refactor: server type name 2025-04-18 15:31:50 +08:00
Nathan.fooo
f6e3290aa4
Merge pull request #7777 from AppFlowy-IO/prepare_auto_sync_chat
refactor: save chat and chat message
2025-04-18 15:31:23 +08:00
Nathan
4925e99166 chore: fix compile 2025-04-18 15:02:02 +08:00
Lucas
3bb5075a98
feat: setup/change password in settings page (#7752)
* feat: change password in settings page

* feat: add change password api

* feat: add password service

* feat: add setup password

* feat: refacotor account page

* chor: update i18n

* chore: i18n

* chore: i18n

* feat: add error message under text field

* fix: flutter tests

* chore: remove long password test

* fix: cloud integration test

* fix: cargo clippy

* fix: replace border color
2025-04-18 14:46:46 +08:00
Nathan
59efb7d9e5 chore: fix test 2025-04-18 14:43:01 +08:00
Nathan
165e95c480 chore: fix test 2025-04-18 14:36:47 +08:00
Nathan
31d8653ba6 refactor: save chat and chat message 2025-04-18 13:34:13 +08:00
Nathan.fooo
80df4955e2
Merge pull request #7754 from AppFlowy-IO/local_chat2
feat: support anon local ai chat/writer
2025-04-17 21:48:05 +08:00
Nathan
5436277ada chore: fmt 2025-04-17 21:39:49 +08:00
Nathan
ed64719560 chore: clippy 2025-04-17 21:09:24 +08:00
Nathan
ac659066c6 chore: return local model 2025-04-17 20:49:24 +08:00
Nathan
3a4d17f054 chore: enable anon ai writer 2025-04-17 16:54:02 +08:00
Nathan
57e4d269eb chore: enable local chat 2025-04-17 16:27:53 +08:00
Nathan
c2339c3522 Merge branch 'main' into local_chat2 2025-04-17 15:49:12 +08:00
Nathan
13065ac726 chore: add tests 2025-04-17 15:47:17 +08:00
Nathan
af91a72187 chore: select message 2025-04-17 14:07:01 +08:00
Nathan
98b835227e chore: remove unused code 2025-04-17 11:22:14 +08:00
Nathan
c633dd0919 chore: remove unused code 2025-04-17 11:11:54 +08:00
Morn
fbf928e6e4
chore: update CHANGELOG.md (#7765) 2025-04-17 11:01:30 +08:00
Lucas
8f63667282
feat: upgrade ubuntu version to 22.04 in github action (#7764)
* feat: upgrade ubuntu version to 22.04 in github action

* fix: cargo clippy
2025-04-17 09:48:23 +08:00
Nathan
e2896b2911 chore: remove inline view id reference 2025-04-16 22:03:14 +08:00
Nathan
77fbf0f8a3 chore: local ai initialize 2025-04-16 21:26:09 +08:00
Nathan
c89f33e2f8 chore: remove unused code 2025-04-16 20:59:25 +08:00
Nathan
e5b6393257 Merge branch 'main' into local_chat2 2025-04-16 20:23:55 +08:00
Nathan.fooo
954e844a21
Merge pull request #7761 from AppFlowy-IO/do_not_initialize_collab
fix: 0.8.9 release bugs
2025-04-16 17:06:31 +08:00
Morn
13acc3af86
fix: auto focus on link name textfield after open the link_edit_menu (#7757)
* fix: auto focus on link name textfield after open the link_edit_menu

* chore: update pubspec.yaml
2025-04-16 16:56:37 +08:00
Nathan
079112c9d2 chore: clippy 2025-04-16 16:30:07 +08:00
Nathan
0be8dcc000 chore: remove duplciate 2025-04-16 16:29:28 +08:00
Nathan
d7d040b0f9 Merge branch 'main' into do_not_initialize_collab 2025-04-16 16:08:29 +08:00
Nathan
f652229718 fix: open relation database row 2025-04-16 16:08:11 +08:00
Lucas
f727dde74b
fix: lose nested children when accepting AI responses (#7760)
* chore: add gitkeep in assets/font dir

* Revert "feat: improve white label scripts on Windows (#7755)"

This reverts commit a5eb2cdd9a.

* chore: use --verbose

* fix: lose nested children when accept ai response

* chore: lock analyzer version

* Reapply "feat: improve white label scripts on Windows (#7755)"

This reverts commit c73186306e.

* chore: lock analyzer version

* chore: update editor version
2025-04-16 15:59:43 +08:00
Nathan
92179fe61c chore: do not get workspace usage when current user is not owner 2025-04-16 15:16:06 +08:00
Nathan
ecfcf3be4d chore: commit pub lock 2025-04-16 14:37:45 +08:00
Nathan
be132d867a chore: lock analyzer version 2025-04-16 14:31:10 +08:00
Nathan
e6951012f0 chore: do not generate search summary if result is empty 2025-04-16 14:21:16 +08:00
Nathan
3214ec075b chore: fix flaky initialize folder indexer 2025-04-16 14:21:16 +08:00
Nathan
be1d2b4b92 chore: delay collab initialization until schema is configured; finalize to ensure collab includes its schema 2025-04-16 14:21:16 +08:00
Lucas
a5eb2cdd9a
feat: improve white label scripts on Windows (#7755)
* feat: improve white label scripts on Windows

* feat: add font white label script

* chore: integrate font white label script
2025-04-16 10:15:40 +08:00
Morn
9b077969e7
fix: error position for slash search result (#7758) 2025-04-16 10:11:31 +08:00
Nathan.fooo
7160790596
Merge pull request #7748 from AppFlowy-IO/adjust_search_ui
chore: loading for search summary
2025-04-15 22:52:49 +08:00
Nathan
5c3e81e6dc chore: adjust ui 2025-04-15 22:49:34 +08:00
Nathan
846172a709 chore: adjust ui 2025-04-15 22:19:16 +08:00
Nathan
f62686fdeb chore: support local chat 2025-04-15 21:07:01 +08:00
Morn
c6511cfb55
fix: mobile slash menu position error (#7747)
* fix: mobile slash menu position error

* fix: mobile slash menu sometimes not showing up
2025-04-15 17:43:50 +08:00
Richard Shiue
69b5452af5
fix: undo not working in database row full page (#7749) 2025-04-15 17:17:39 +08:00
Nathan
9291236733 chore: clippy 2025-04-15 16:49:33 +08:00
Nathan
3b3ae7fde9 chore: loading for search summary 2025-04-15 15:59:15 +08:00
Morn
d9748d5ef1
fix: block does not expand with grid size (#7745)
* fix: block does not expand with grid size

* fix: replace listenForSizeChanged with SizeChangedLayoutNotifier
2025-04-15 13:50:53 +08:00
Richard Shiue
d01909830d
fix: ai writer not working in database rows (#7746)
* fix: ai writer not working in database rows

* chore: dart analysis
2025-04-15 11:14:20 +08:00
Nathan.fooo
03ecc3d6c9
Merge pull request #7727 from AppFlowy-IO/search_summary
chore: search summary
2025-04-15 10:37:17 +08:00
Lucas
1630e11c4d
chore: update login page icons and i18n (#7742)
* feat: update i18n and icons

* chore: replace appflowy with welcome to appflowy

* fix: protenial delete page error

* fix: flutter analyze
2025-04-15 09:39:33 +08:00
Nathan
99fb6ab743 chore: fix linux watch 2025-04-14 23:07:42 +08:00
Nathan
35bc095760 chore: local and server result 2025-04-14 22:58:37 +08:00
Nathan
a44ad63230 chore: display preview 2025-04-14 16:40:54 +08:00
Lucas
ddbaf0d530
feat: otp improvement on mobile (#7738) 2025-04-14 14:58:00 +08:00
Nathan
437deaf986 chore: update logs 2025-04-14 14:38:25 +08:00
Morn
54df6b2863
fix: link_preview launch review issues (#7731)
* fix: some link_preview launch review issues

* fix: some UI issues

* chore: pasting a link will not check whether it is an image

* fix: copy link to block not supported well

* fix: mention UI issues

* feat: support get youtube channel info

* chore: update translation

* feat: add shadow in appflowy theme

* chore: remove AFThemeExtensionV2

* fix: some UI issues
2025-04-14 14:14:05 +08:00
Richard Shiue
69b98cb323
fix: open board row as page (#7735) 2025-04-14 10:47:55 +08:00
Nathan
4d172761ce chore: search with ai summary 2025-04-14 10:36:42 +08:00
Lucas
8c4324ee9d
fix: otp launch review issues (#7730)
* fix: add error text under text field

* chore: update translation
2025-04-14 10:29:46 +08:00
Lucas
2e295e6891
fix: unable to accept the response from 'mark longer' (#7725)
* fix: unable to accpet 'make it longer'

* fix: markdown text robot test
2025-04-11 14:25:19 +08:00
Richard Shiue
351c891a5a
fix: adjust ghost icon hover color (#7723) 2025-04-11 10:21:02 +08:00
Richard Shiue
bcb1e8e4f5
chore: adjust replace, insert below, discard selection behavior (#7719) 2025-04-11 09:57:41 +08:00
Morn
4997ac99cf
feat: revamp link preivew (#7692)
* feat: revamp link preivew

* feat: add convert to menu for link hover menu

* feat: add mention link

* feat: support convert preview to mention

* feat: add embed link preview

* fix: some test erros

* fix: test errors

* fix: some UI issues

* chore: add test for url

* chore: add test for mention

* chore: add test for bookmark

* chore: add test for embed

* chore: remove unuse import

* fix: some UI issues

* fix: remove text span overlay on mobile

* fix: code lint error

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2025-04-10 14:45:13 +08:00
Lucas
e3bbabd63e
feat: sign in with passcode (#7685)
* feat: sign in with password or passcode

* feat: add loading dialog

* chore: update translations

* feat: support otp on mobile
2025-04-10 14:06:08 +08:00
Morn
403c558f9b
chore: update translation (#7675) 2025-04-10 13:45:22 +08:00
Nathan.fooo
31d2c1d603
Merge pull request #7718 from AppFlowy-IO/remove_path_to_lai
chore: remove local ai related path to lai repo
2025-04-09 21:56:40 +08:00
Nathan
a7adeeadb1 chore: remove local ai related path to lai repo 2025-04-09 20:47:03 +08:00
Nathan.fooo
f7d7141a59
Merge pull request #7717 from AppFlowy-IO/fix_local_ai_setting
chore: fix local ai setting
2025-04-09 20:43:36 +08:00
Nathan
2371d4691d chore: fix local ai setting 2025-04-09 20:07:06 +08:00
Nathan
9ec0437673 chore: remove enable sync log toggle 2025-04-09 14:15:48 +08:00
Richard Shiue
89525b7f7b
chore: bump version (#7716) 2025-04-09 14:11:33 +08:00
Richard Shiue
ad227bcf79
chore: toast message when theme folder doesn't have permission (#7715) 2025-04-09 13:45:07 +08:00
Lucas
8b82d532e0
fix: replace the selected text with ai response in the same line (#7708)
* fix: replace the selected text with ai response in the same line

* fix: replace the selected text with ai response in the multiple lines

* fix: integrate the replace function in the ai writer cubit

* fix: unit test and integration test
2025-04-09 13:21:19 +08:00
Morn
f4af47728f
fix: overwriting a selected text removes more than expected (#7714) 2025-04-09 12:57:58 +08:00
Richard Shiue
b3a4a9ba04
fix: use normal underline for AI suggested text style (#7712) 2025-04-09 10:25:29 +08:00
Lucas
13028c6cfe
fix: icon and emoji doesn't align in mention page menu (#7673) 2025-04-09 09:39:35 +08:00
Richard Shiue
7d0f6c9deb
fix(mobile): edit mention page (#7698)
* fix(mobile): edit mention page

* chore: type check

* chore: replace usages

* chore: use MentionType instead
2025-04-09 09:38:42 +08:00
FakhriAzzouz
8d2901cc58
chore: arabic translations update (#7701)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-04-09 09:38:00 +08:00
Aniket Patil
bbc7b6d172
feat(i18n): add initial Marathi (mr-IN) translations (#7667)
* feat(i18n): Add initial Marathi (mr-IN) translations

* fix(i18n): Force add mr-IN.json in assets folder for localization

* fix(i18n): Sync mr-IN.json from assets to resources

* fix(i18n): Final sync of full Marathi translation in assets

* feat(i18n): Force add Marathi translations to ignored directory

* feat(i18n): Complete Marathi translations
2025-04-09 09:37:21 +08:00
Lucas
145d1e5fdb
feat: support white label on windows (#7706) 2025-04-09 09:36:53 +08:00
Nathan.fooo
eae4e42dcc
Merge pull request #7709 from AppFlowy-IO/refactor_path_name
chore: rename data path
2025-04-08 21:19:07 +08:00
Nathan
6886261692 chore: ensure path 2025-04-08 20:49:15 +08:00
Nathan
23f2d85e70 chore: clippy 2025-04-08 20:23:25 +08:00
Nathan
d1d598940d chore: clippy 2025-04-08 16:08:28 +08:00
Nathan
efb98d28ef chore: rename data path 2025-04-08 15:53:34 +08:00
Richard Shiue
462c822255
fix: translations (#7694) 2025-04-08 13:08:10 +08:00
Richard Shiue
9e30b1816f
fix(flutter_desktop): grid bug (#7697)
* fix: field width doesn't persist

* test: add test

* test: fix test

* test: grid width integration test
2025-04-08 11:46:06 +08:00
Nathan.fooo
d79929d1c9
Merge pull request #7700 from AppFlowy-IO/sync_log_enable_default
chore: enable sync log by default
2025-04-08 10:31:01 +08:00
Nathan
0286678286 chore: clippy 2025-04-08 10:30:48 +08:00
Nathan
4896d7c1be chore: enable sync log by default 2025-04-07 22:04:32 +08:00
Nathan.fooo
b06a2337a0
Merge pull request #7699 from AppFlowy-IO/use_uuid
Chore: Use UUID instead of using String
2025-04-07 21:54:48 +08:00
Nathan
20bcdd1f90 chore: fmt 2025-04-07 21:28:48 +08:00
Nathan
24d57336a9 chore: bump collab id 2025-04-07 21:15:30 +08:00
Nathan
91397c963a chore: clippy 2025-04-07 19:34:26 +08:00
Nathan
995b773c74 chore: replace str with uuid 2025-04-07 19:24:58 +08:00
Richard Shiue
c561abd9f8
fix: clear text field upon selection (#7695) 2025-04-07 14:52:44 +08:00
Lucas
10dd0fa438
feat: implement new color tokens design (#7684)
* fix: missing AFThemeExtensionV2 on mobile

* feat: add appflowy_ui package
2025-04-04 14:41:13 +08:00
Lucas
d74b0bf6e1
fix: only log document event when enableDocumentInternalLog is enabled (#7676) 2025-04-02 16:09:52 +08:00
Morn
913924d8d3
fix: emoji menu should only be triggered when “:” has a followed letter (#7672) 2025-04-02 14:15:14 +08:00
Nathan.fooo
7bc9ce4391
Merge pull request #7674 from AppFlowy-IO/custom_compare_func
fix: after applying different ai model, the selection menu doesn't show the new model
2025-04-02 13:59:04 +08:00
Nathan
ed5334a7d6 chore: clippy 2025-04-02 13:38:29 +08:00
Nathan
120f22c6fc chore: notify model change after applying local ai model 2025-04-02 13:08:45 +08:00
FakhriAzzouz
f8a17dac00
chore: update translations with Fink 🐦 (#7669) 2025-04-02 09:50:50 +08:00
Lucas
7f41feb959
chore: update changelog (#7671) 2025-04-01 21:41:20 +08:00
Nathan.fooo
da7f584da7
Merge pull request #7670 from richardshiue/fix/hotfix
fix: translation hotfix
2025-04-01 21:34:06 +08:00
Richard Shiue
031a88f4c4 fix: translation hotfix 2025-04-01 21:26:24 +08:00
Lucas
e2ff12415c
chore: update translation (#7666) 2025-04-01 17:35:22 +08:00
Morn
edd59cf462
fix: link menu issues (#7661)
* fix: duplicated link menu issue

* fix: support toolbar animation

* chore: update appflowy_editor

* fix: update pubspect.lock

* fix: testing error

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-04-01 14:17:04 +08:00
Morn
b52f681e3c
fix: launch review issues for non-search-bar emoji picker (#7654)
* fix: some launch review issues for emoji picker

* fix: revamp emoji picker which is created by character

* fix: add padding for non-searchbar emoji picker
2025-04-01 09:33:54 +08:00
Lucas
5d33d723e9
fix: the checklist item will disappear when reordering (#7659) 2025-04-01 09:25:49 +08:00
Morn
5e1a8b1ec7
fix: toolbar link launch review issues (#7639)
* fix: some toolbar link launch review issues

* fix: support check link format for link menus

* fix: toolbar and link hover menu will not display together

* fix: filter link search result with current document id

* fix: remove error text while link menu is not focus

* fix: some issues

* fix: test errors

* fix: add tooltip for link menu
2025-03-31 15:16:56 +08:00
Richard Shiue
3c99105b23
fix: show cursor at end of selection when generating (#7655) 2025-03-31 15:06:50 +08:00
Richard Shiue
0b298ea426
chore: use emoji instead of Local for copy (#7656) 2025-03-31 15:06:39 +08:00
Lucas
7ed36f8736
feat: support i18n in delete my account operation (#7635) 2025-03-31 13:07:42 +08:00
Richard Shiue
34d2b7f24e
chore: improve local ai settings page (#7651)
* chore: improve local ai settings page

* chore: move local to top in select list

* chore: improve copy of missing model

* chore: remove green background and add progress indicator

* chore: change text color
2025-03-31 12:05:11 +08:00
Richard Shiue
a2303d35e8
chore: hide formats when using specific ai writer commands (#7648)
* chore: hide formats for specific ai writer features

* chore: use black color for selected model name
2025-03-31 10:24:46 +08:00
Nathan.fooo
c87d9ab74f
Merge pull request #7652 from AppFlowy-IO/show_not_ready
chore: display message when using ai writer with local ai, but local ai  is disabled
2025-03-30 22:16:05 +08:00
Nathan
e3a0806eee chore: clippy 2025-03-30 20:53:27 +08:00
Nathan
b63c4dfe21 chore: show local ai disable 2025-03-30 20:12:20 +08:00
Nathan
2277d7d234 chore: show local ai disable 2025-03-30 15:15:59 +08:00
Nathan
f76ce2be14 chore: show not ready state when using ai writer with local ai 2025-03-30 12:06:08 +08:00
Nathan.fooo
3c74208ab9
Merge pull request #7650 from AppFlowy-IO/display_plugin_version
chore: show plugin version
2025-03-30 12:05:25 +08:00
Nathan
5ae3f42313 chore: fix windows build 2025-03-30 11:28:29 +08:00
Nathan
af5c4bfe76 chore: show plugin version 2025-03-30 10:48:26 +08:00
Nathan.fooo
9c25769d8e
Merge pull request #7649 from AppFlowy-IO/revert-7643-chore/improve-local-ai-setting-page
Revert "chore: improve local ai settings page"
2025-03-30 09:54:26 +08:00
Nathan.fooo
12a4bf67f6
Revert "feat: improve local ai settings page UI (#7643)"
This reverts commit 3a879b0186.
2025-03-30 09:54:08 +08:00
Nathan
34a858e948 chore: show plugin version 2025-03-30 09:02:06 +08:00
Richard Shiue
3a879b0186
feat: improve local ai settings page UI (#7643)
* chore: improve local ai settings page

* chore: improve local ai settings page

* chore: simplify state and improve ui
2025-03-29 21:56:05 +08:00
Nathan.fooo
dbe72b32b2
Merge pull request #7646 from AppFlowy-IO/show_lai_resource
chore: bump lai
2025-03-29 19:58:08 +08:00
Nathan
917aa60c98 chore: bump lai 2025-03-29 15:32:45 +08:00
Nathan.fooo
84e0f5e6ff
Merge pull request #7595 from AppFlowy-IO/mcp_protocol
chore: implement MCP client
2025-03-29 09:10:05 +08:00
Nathan
7b89d76cea chore: clippy 2025-03-28 23:31:00 +08:00
Nathan
fbf031b06d chore: bump local ai 2025-03-28 23:00:27 +08:00
Nathan
671e855b0e Merge branch 'main' into mcp_protocol 2025-03-28 16:27:19 +08:00
Nathan
8aa32ca3fa chore: disable mcp 2025-03-28 16:27:16 +08:00
Richard Shiue
07c767c4fa
fix: ai writer ux issues (#7640)
* chore: fix local ai font weight

* chore: adjust auto response format copy

* fix: escape shortcut

* chore: more action button icon size
2025-03-28 11:23:29 +08:00
Nathan.fooo
4ce0782a05
Merge pull request #7638 from AppFlowy-IO/ai_model_desc
Ai model desc
2025-03-27 22:07:00 +08:00
Nathan
7456c65799 chore: clippy 2025-03-27 20:54:48 +08:00
Nathan
8cf31b8afc chore: bump client api 2025-03-27 20:51:29 +08:00
Nathan
ff70595a15 chore: display ai model desc and fix flashing when select model 2025-03-27 20:03:07 +08:00
Nathan
f6f19a0a07 chore: display ai model desc on setting page 2025-03-27 19:17:52 +08:00
Richard Shiue
b83b964678 chore: add model description UI 2025-03-27 18:01:45 +08:00
Nathan
f574b6b9c2 chore: update default name 2025-03-27 17:10:35 +08:00
Nathan
7ee29dcbc5 chore: return model desc 2025-03-27 17:09:01 +08:00
Nathan
d348361889 chore: return desc of ai model 2025-03-27 16:31:18 +08:00
Richard Shiue
07a78b4ad7
chore: adjust default model name (#7634) 2025-03-27 14:26:45 +08:00
Morn
a26ebbccc1
fix: toolbar launch review issues (#7631)
* fix: keep the turn into menu within six-dot same as toolbar

* fix: change some icon color within toolbar

* fix: improve toolbar

* chore: update editor dependency

* fix: update editor dependency
2025-03-27 14:19:51 +08:00
Nathan
ccb020e885 chore: bump lai 2025-03-27 14:19:27 +08:00
Lucas
76cb23e233
feat: add bloc observer (#7633)
* chore: bump version 0.8.8

* feat: add bloc observer

* chore: update comment

* chore: update comment
2025-03-27 14:17:47 +08:00
Morn
4686e13390
feat: add animation for floating toolbar (#7623) 2025-03-27 13:28:35 +08:00
Nathan.fooo
584f762e11
Merge pull request #7617 from richardshiue/chore/improve-model-selection-ui
feat: regenerate message with different model
2025-03-27 12:54:26 +08:00
Richard Shiue
8528811992 chore: remove ai model class 2025-03-27 12:02:58 +08:00
Richard Shiue
9147f64b65
chore: adjust suggestion action button position (#7632) 2025-03-27 11:10:23 +08:00
Richard Shiue
f7f2e71ee1
chore: adjust popover shadows (#7630) 2025-03-27 10:05:26 +08:00
Richard Shiue
f9e1dcca6c chore: code nit 2025-03-27 00:15:48 +08:00
Richard Shiue
e11388491f chore: emit ready state faster 2025-03-27 00:15:48 +08:00
Richard Shiue
eb0cff36c9 chore: improve ai model selection ui 2025-03-27 00:15:48 +08:00
Nathan
ac8141ab15 Merge branch 'main' into mcp_protocol 2025-03-26 16:18:46 +08:00
Richard Shiue
b3b13e550d
chore: adjust popover shadows (#7626) 2025-03-26 14:29:11 +08:00
Nathan.fooo
ba1767e312
Merge pull request #7628 from AppFlowy-IO/local_ai_global_config
chore: implement local ai config
2025-03-26 14:29:02 +08:00
Nathan
10048dadec Merge branch 'main' into local_ai_global_config 2025-03-26 14:21:12 +08:00
Nathan
815bb11cde chore: implement local ai config 2025-03-26 14:19:57 +08:00
Lucas
7372f5583c
chore: bump version 0.8.8 (#7627) 2025-03-26 13:56:24 +08:00
Lucas
9115e208ac
fix: numbered list generated by ai should keep the same index as the input (#7622)
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-03-26 13:31:32 +08:00
Morn
24bb1b58a0
feat: support use ":" keyword to create emojis (#7582)
* feat: add ability to use : keyword to create emojis(#2797)

* fix: emoji position error

* chore: add integration test

* chore: dismiss emoji picker while starting searching with space
2025-03-26 13:24:16 +08:00
Ametero
cfca70ae14
chore: update translations with Fink 🐦 (#7584) 2025-03-26 13:23:25 +08:00
Morn
1db6da7024
fix: undo not working for toggle heading (#7598) 2025-03-26 13:22:47 +08:00
Richard Shiue
c212c568e9
chore: bump editor ref (#7621) 2025-03-26 11:00:41 +08:00
Richard Shiue
1d437fb81e
chore: adjust ai writer popover content area (#7618) 2025-03-25 23:44:00 +08:00
Richard Shiue
5a0478ad56
fix(mobile): settings page crashes (#7616) 2025-03-25 23:00:28 +08:00
Richard Shiue
039c191d1f
fix: ai text insertion (#7615)
* fix: dont scroll to ai writer node if path not found

* chore: rename text robot clear method and add reset

* fix: insert position is off if using ai writer multiple times

* chore: reorganize code

* fix: undo not working after accept
2025-03-25 22:41:35 +08:00
Richard Shiue
eb4b015de8
chore: bump editor plugin ref (#7610) 2025-03-25 20:37:15 +08:00
Khor Shu Heng
5269bfbf8e
Merge pull request #7609 from ixicut/fix-ukrainian-translation-7603
fix: correct translation inconsistencies
2025-03-25 18:05:30 +08:00
ixicut
ed44c20281 fix: correct translation inconsistencies 2025-03-25 11:19:11 +02:00
Nathan.fooo
878323a299
Merge pull request #7606 from AppFlowy-IO/fix_exceptions
chore: fix incorrect local ai state
2025-03-25 13:59:49 +08:00
Nathan
0dc2363962 chore: fix incorrect local ai state 2025-03-25 13:05:18 +08:00
Richard Shiue
72d660f1ac
fix: number cell update flashes with old content (#7605) 2025-03-25 11:07:46 +08:00
Nathan.fooo
46532a861f
Merge pull request #7602 from AppFlowy-IO/remove_default_model
chore: remove default model name
2025-03-24 22:37:23 +08:00
Nathan
4c39908748 chore: separate model 2025-03-24 22:21:44 +08:00
Nathan
35081fd311 chore: remove default model name 2025-03-24 21:59:42 +08:00
Richard Shiue
7463e4e3eb
fix: pass response format in ai writer (#7599) 2025-03-24 16:50:16 +08:00
Nathan
66ce786726 chore: add client 2025-03-24 16:38:37 +08:00
Richard Shiue
682a50da53
chore: replace ai response every time (#7597) 2025-03-24 14:15:20 +08:00
Nathan
d372abd5a1 Merge branch 'main' into mcp_protocol 2025-03-24 13:56:56 +08:00
Richard Shiue
dfb5a6629f
chore: pass ai writer context (#7596)
* chore: pass ai writer context

* chore: maintain selection after starting ai writer

* chore: improve ui of additional comments

* chore: revert podfile.lock changes

* chore: code readability

* chore: revert podfile.lock changes

* fix: accept shouldn't try to unformat
2025-03-24 13:27:31 +08:00
Nathan.fooo
bb72f7e70a
Merge pull request #7590 from AppFlowy-IO/support_switch_model
chore: support switch ai model in chat/ AI writer
2025-03-24 12:40:43 +08:00
Nathan
37085042f8 chore: clippy 2025-03-24 12:40:29 +08:00
Nathan
2cbcb320fe chore: add timeout 2025-03-24 12:11:12 +08:00
Nathan
949556e2fa chore: remove tauri feature 2025-03-24 12:03:42 +08:00
Nathan
08d1d3602e chore: implement MCP client 2025-03-24 10:44:20 +08:00
Morn
910c45e457
chore: change some mobile slash menu icons (#7579) 2025-03-24 09:55:58 +08:00
Morn
6f031d0c7e
feat: revamp toolbar link (#7578)
* feat: revamp toolbar link

* fix: some review issues

* chore: add integration test for toolbar link
2025-03-24 09:55:23 +08:00
Morn
44c9d572c8
fix: using date picker with @ menu with some errors (#7580) 2025-03-24 09:54:45 +08:00
Nathan
05949d2f87 chore: support switch ai model in chat or ai writer 2025-03-23 21:53:05 +08:00
Nathan.fooo
ad695e43b9
Merge pull request #7575 from AppFlowy-IO/completion_stream_v2
chore: completion stream v2
2025-03-21 11:47:28 +08:00
Nathan
87015f7133 chore: upgrade lai commit 2025-03-21 11:47:05 +08:00
Richard Shiue
deb019aa4a chore: don't allow double register two ai nodes 2025-03-21 11:14:49 +08:00
Richard Shiue
c79d014305 chore: revert podfile changes and update error 2025-03-21 10:49:41 +08:00
Richard Shiue
182101023b chore: remove actions in explanation when not explain 2025-03-21 10:37:10 +08:00
Richard Shiue
f1b2f51a06 chore: fix test 2025-03-21 10:20:47 +08:00
Nathan
a5c0ad5998 chore: fix test 2025-03-21 10:07:27 +08:00
Nathan
6fd250d4d1 chore: update client api 2025-03-21 10:04:51 +08:00
Nathan
db2270c8d8 chore: update local ai commit 2025-03-20 15:13:25 +08:00
Richard Shiue
10f19069c6 chore: implement ui 2025-03-20 13:30:42 +08:00
Richard Shiue
13a7ea07a8 chore: merge remote-tracking branch 'upstream/main' into this one 2025-03-20 13:29:03 +08:00
Richard Shiue
aacd795ae0
chore: ai writer more actions (#7576)
* feat: add more button to ai writer input box

* chore: adjust button padding

* chore: adjust icon color
2025-03-20 12:33:51 +08:00
Richard Shiue
566e7b2f40
feat: allow user scroll during generation (#7559)
* chore: add keep alive to ai writer block component

* chore: allow user scrolling during ai writer generation

* chore: pull ai writer cubit upwards

* test: fix unit tests

* chore: clear selection
2025-03-20 11:50:25 +08:00
Nathan
f413b9e070 chore: callback 2025-03-20 11:44:02 +08:00
Nathan
6e4206a8e2 chore: completion stream v2 2025-03-20 11:41:49 +08:00
735 changed files with 42917 additions and 11575 deletions

View file

@ -338,7 +338,7 @@ jobs:
- {
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-20.04,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}

View file

@ -1,4 +1,35 @@
# Release Notes
## Version 0.8.9 - 16/04/2025
### Desktop
#### New Features
- Supported pasting a link as a mention, providing a more condensed visualization of linked content
- Supported converting between link formats (e.g. transforming a mention into a bookmark)
- Improved the link editing experience with enhanced UX
- Added OTP (One-Time Password) support for sign-in authentication
- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet
#### Bug Fixes
- Fixed an issue where properties were not displaying in the row detail page
- Fixed a bug where Undo didn't work in the row detail page
- Fixed an issue where blocks didn't grow when the grid got bigger
- Fixed several bugs related to AI writers
### Mobile
#### New Features
- Added sign-in with OTP (One-Time Password)
#### Bug Fixes
- Fixed an issue where the slash menu sometimes failed to display
- Updated the mention page block to handle page selection with more context.
## Version 0.8.8 - 01/04/2025
### New Features
- Added support for selecting AI models in AI writer
- Revamped link menu in toolbar
- Added support for using ":" to add emojis in documents
- Passed the history of past AI prompts and responses to AI writer
### Bug Fixes
- Improved AI writer scrolling user experience
- Fixed issue where checklist items would disappear during reordering
- Fixed numbered lists generated by AI to maintain the same index as the input
## Version 0.8.7 - 18/03/2025
### New Features
- Made local AI free and integrated with Ollama

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.8.7"
APPFLOWY_VERSION = "0.8.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"

View file

@ -4,6 +4,7 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "packages/**/*.dart"
linter:
rules:

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,6 @@ void main() {
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapContinousAnotherWay();
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();

View file

@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@ -44,12 +44,12 @@ void main() {
await tester.pumpAndSettle(const Duration(milliseconds: 200));
// Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna)
expect(find.byType(SearchResultTile), findsNWidgets(2));
expect(find.byType(SearchResultCell), findsNWidgets(2));
// The score should be higher for "ViewOna" thus it should be shown first
final secondDocumentWidget = tester
.widget(find.byType(SearchResultTile).first) as SearchResultTile;
expect(secondDocumentWidget.result.data, secondDocument);
.widget(find.byType(SearchResultCell).first) as SearchResultCell;
expect(secondDocumentWidget.item.displayName, secondDocument);
// Change search to "ViewOne"
await tester.enterText(searchFieldFinder, firstDocument);
@ -57,9 +57,9 @@ void main() {
// The score should be higher for "ViewOne" thus it should be shown first
final firstDocumentWidget = tester.widget(
find.byType(SearchResultTile).first,
) as SearchResultTile;
expect(firstDocumentWidget.result.data, firstDocument);
find.byType(SearchResultCell).first,
) as SearchResultCell;
expect(firstDocumentWidget.item.displayName, firstDocument);
});
testWidgets('Displaying icons in search results', (tester) async {
@ -89,11 +89,11 @@ void main() {
);
await tester.enterText(searchFieldFinder, 'Page-$randomValue');
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(find.byType(SearchResultTile), findsNWidgets(2));
expect(find.byType(SearchResultCell), findsNWidgets(2));
/// check results
final svgs = find.descendant(
of: find.byType(SearchResultTile),
of: find.byType(SearchResultCell),
matching: find.byType(FlowySvg),
);
expect(svgs, findsNWidgets(2));

View file

@ -1,5 +1,5 @@
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -27,11 +27,12 @@ void main() {
expect(find.byType(RecentViewsList), findsOneWidget);
// Expect three recent history items
expect(find.byType(RecentViewTile), findsNWidgets(3));
expect(find.byType(SearchRecentViewCell), findsNWidgets(3));
// Expect the first item to be the last viewed document
final firstDocumentWidget =
tester.widget(find.byType(RecentViewTile).first) as RecentViewTile;
tester.widget(find.byType(SearchRecentViewCell).first)
as SearchRecentViewCell;
expect(firstDocumentWidget.view.name, secondDocument);
});
});

View file

@ -15,6 +15,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
@ -29,6 +30,11 @@ void main() {
await tester.tapHidePropertyButton();
tester.noFieldWithName('New field 1');
// create another field, New field 1 to be hidden still
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
tester.noFieldWithName('New field 1');
// go back to inline database view, expect field to be shown
await tester.tapTabBarLinkedViewByViewName('Untitled');
tester.findFieldWithName('New field 1');
@ -60,5 +66,40 @@ void main() {
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
});
testWidgets('field cell width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// check the width of the field
expect(tester.getFieldWidth('New field 1'), 150);
// change the width of the field
await tester.changeFieldWidth('New field 1', 200);
expect(tester.getFieldWidth('New field 1'), 205);
// create another field, New field 1 to be same width
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
expect(tester.getFieldWidth('New field 1'), 205);
// go back to inline database view, expect New field 1 to be 150px
await tester.tapTabBarLinkedViewByViewName('Untitled');
expect(tester.getFieldWidth('New field 1'), 150);
// go back to linked database view, expect New field 1 to be 205px
await tester.tapTabBarLinkedViewByViewName('Grid');
expect(tester.getFieldWidth('New field 1'), 205);
});
});
}

View file

@ -1,5 +1,10 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -73,5 +78,37 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('insert grid in column', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create page and show slash menu
await tester.createNewPageWithNameUnderParent(name: 'test page');
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.pumpAndSettle();
/// create a column
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_twoColumns.tr(),
);
final actionList = find.byType(BlockActionList);
expect(actionList, findsNWidgets(2));
final position = tester.getCenter(actionList.last);
/// tap the second child of column
await tester.tapAt(position.copyWith(dx: position.dx + 50));
/// create a grid
await tester.editor.showSlashMenu();
await tester.pumpAndSettle();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_grid.tr(),
);
final grid = find.byType(GridPageContent);
expect(grid, findsOneWidget);
});
});
}

View file

@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:io';
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';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@ -320,8 +322,14 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
final pasteAsMenu = find.byType(PasteAsMenu);
expect(pasteAsMenu, findsOneWidget);
final bookmarkButton = find.text(
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
);
await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
@ -333,19 +341,20 @@ void main() {
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
/// show menu
final menu = find.byType(CustomLinkPreviewMenu);
expect(menu, findsOneWidget);
await tester.tapButton(menu);
final convertToLinkButton = find.text(
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
.tr(),
);
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await tester.pumpAndSettle();
await tester.tapButton(convertToLinkButton);
},
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
@ -363,14 +372,19 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
final pasteAsMenu = find.byType(PasteAsMenu);
expect(pasteAsMenu, findsOneWidget);
final bookmarkButton = find.text(
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
);
await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
@ -469,16 +483,6 @@ void main() {
});
});
testWidgets('paste image url without extension', (tester) async {
const plainText =
'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';
await tester.pasteContent(plainText: plainText, (editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
const testMarkdownText = '''
# I'm h1
## I'm h2
@ -521,7 +525,7 @@ void main() {
extension on WidgetTester {
Future<void> pasteContent(
void Function(EditorState editorState) test, {
FutureOr<void> Function(EditorState editorState) test, {
Future<void> Function(EditorState editorState)? beforeTest,
String? plainText,
String? html,
@ -558,6 +562,6 @@ extension on WidgetTester {
);
await pumpAndSettle(const Duration(milliseconds: 1000));
test(editor.getCurrentEditorState());
await test(editor.getCurrentEditorState());
}
}

View file

@ -13,6 +13,8 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final finder = find.text(gettingStarted, findRichText: true);
await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2));
// create a new document
const pageName = 'Test Document';

View file

@ -0,0 +1,453 @@
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/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart';
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/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const avaliableLink = 'https://appflowy.io/',
unavailableLink = 'www.thereIsNoting.com';
Future<void> preparePage(WidgetTester tester, {String? pageName}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: pageName);
await tester.editor.tapLineOfEditorAt(0);
}
Future<void> pasteLink(WidgetTester tester, String link) async {
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
/// paste the link
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle(Duration(seconds: 1));
}
Future<void> pasteAs(
WidgetTester tester,
String link,
PasteMenuType type, {
Duration waitTime = const Duration(milliseconds: 500),
}) async {
await pasteLink(tester, link);
final convertToMentionButton = find.text(type.title);
await tester.tapButton(convertToMentionButton);
await tester.pumpAndSettle(waitTime);
}
void checkUrl(Node node, String link) {
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': link,
'attributes': {'href': link},
}
]);
}
void checkMention(Node node, String link) {
final delta = node.delta!;
final insert = (delta.first as TextInsert).text;
final attributes = delta.first.attributes;
expect(insert, MentionBlockKeys.mentionChar);
final mention =
attributes?[MentionBlockKeys.mention] as Map<String, dynamic>;
expect(mention[MentionBlockKeys.type], MentionType.externalLink.name);
expect(mention[MentionBlockKeys.url], avaliableLink);
}
void checkBookmark(Node node, String link) {
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], link);
}
void checkEmbed(Node node, String link) {
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed);
expect(node.attributes[LinkPreviewBlockKeys.url], link);
}
group('Paste as URL', () {
Future<void> pasteAndTurnInto(
WidgetTester tester,
String link,
String title,
) async {
await pasteLink(tester, link);
final convertToLinkButton = find
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
await tester.tapButton(convertToLinkButton);
/// hover link and turn into mention
await tester.hoverOnWidget(
find.byType(LinkHoverTrigger),
onHover: () async {
final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(turnintoButton);
final convertToButton = find.text(title);
await tester.tapButton(convertToButton);
await tester.pumpAndSettle(Duration(seconds: 1));
},
);
}
testWidgets('paste a link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteLink(tester, link);
final convertToLinkButton = find
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
await tester.tapButton(convertToLinkButton);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link and turn into mention', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toMention.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link and turn into bookmark', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toBookmark.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste a link and turn into embed', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toEmbed.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
});
group('Paste as Mention', () {
Future<void> pasteAsMention(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.mention);
String getMentionLink(Node node) {
final insert = node.delta?.first as TextInsert?;
final mention = insert?.attributes?[MentionBlockKeys.mention]
as Map<String, dynamic>?;
return mention?[MentionBlockKeys.url] ?? '';
}
Future<void> hoverMentionAndClick(
WidgetTester tester,
String command,
) async {
final mentionLink = find.byType(MentionLinkBlock);
expect(mentionLink, findsOneWidget);
await tester.hoverOnWidget(
mentionLink,
onHover: () async {
final errorPreview = find.byType(MentionLinkErrorPreview);
expect(errorPreview, findsOneWidget);
final convertButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(convertButton);
final menuButton = find.text(command);
await tester.tapButton(menuButton);
},
);
}
testWidgets('paste a link as mention', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste as mention and copy link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
final mentionLink = find.byType(MentionLinkBlock);
expect(mentionLink, findsOneWidget);
await tester.hoverOnWidget(
mentionLink,
onHover: () async {
final preview = find.byType(MentionLinkPreview);
if (!preview.hasFound) {
final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(copyButton);
} else {
final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
await tester.tapButton(moreOptionButton);
final copyButton =
find.text(MentionLinktMenuCommand.copyLink.title);
await tester.tapButton(copyButton);
}
},
);
final clipboardContent = await getIt<ClipboardService>().getData();
expect(clipboardContent.plainText, link);
});
testWidgets('paste as error mention and turninto url', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toURL.title,
);
node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste as error mention and turninto embed', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toEmbed.title,
);
node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste as error mention and turninto bookmark', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toBookmark.title,
);
node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste as error mention and remove link', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.removeLink.title,
);
node = tester.editor.getNodeAtPath([0]);
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{'insert': link},
]);
});
});
group('Paste as Bookmark', () {
Future<void> pasteAsBookmark(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.bookmark);
Future<void> hoverAndClick(
WidgetTester tester,
LinkPreviewMenuCommand command,
) async {
final bookmark = find.byType(CustomLinkPreviewBlockComponent);
expect(bookmark, findsOneWidget);
await tester.hoverOnWidget(
bookmark,
onHover: () async {
final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
await tester.tapButton(menuButton);
final commandButton = find.text(command.title);
await tester.tapButton(commandButton);
},
);
}
testWidgets('paste a link as bookmark', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste a link as bookmark and convert to mention',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link as bookmark and convert to url', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link as bookmark and convert to embed',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed);
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste a link as bookmark and copy link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink);
final clipboardContent = await getIt<ClipboardService>().getData();
expect(clipboardContent.plainText, link);
});
testWidgets('paste a link as bookmark and replace link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.replace);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
await tester.enterText(find.byType(TextFormField), unavailableLink);
await tester.tapButton(find.text(LocaleKeys.button_replace.tr()));
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, unavailableLink);
});
testWidgets('paste a link as bookmark and remove link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink);
final node = tester.editor.getNodeAtPath([0]);
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{'insert': link},
]);
});
});
group('Paste as Embed', () {
Future<void> pasteAsEmbed(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.embed);
Future<void> hoverAndConvert(
WidgetTester tester,
LinkEmbedConvertCommand command,
) async {
final embed = find.byType(LinkEmbedBlockComponent);
expect(embed, findsOneWidget);
await tester.hoverOnWidget(
embed,
onHover: () async {
final menuButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(menuButton);
final commandButton = find.text(command.title);
await tester.tapButton(commandButton);
},
);
}
testWidgets('paste a link as embed', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste a link as bookmark and convert to mention',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, LinkEmbedConvertCommand.toMention);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link as bookmark and convert to url', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, LinkEmbedConvertCommand.toURL);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link as bookmark and convert to bookmark',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, LinkEmbedConvertCommand.toBookmark);
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
});
}

View file

@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.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';
@ -47,5 +48,41 @@ void main() {
expect(editorState.selection!.start.offset, 0);
});
testWidgets('select and delete text', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// input text
final editor = tester.editor;
final editorState = editor.getCurrentEditorState();
const inputText = 'Test for text selection and deletion';
final texts = inputText.split(' ');
await editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
/// selecte and delete
int index = 0;
while (texts.isNotEmpty) {
final text = texts.removeAt(0);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: index),
end: Position(path: [0], offset: index + text.length),
),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
index++;
}
/// excpete the text value is correct
final node = editorState.getNodeAtPath([0])!;
final nodeText = node.delta?.toPlainText() ?? '';
expect(nodeText, ' ' * (index - 1));
});
});
}

View file

@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
import 'document_link_preview_test.dart' as document_link_preview_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -28,4 +29,5 @@ void main() {
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
document_link_preview_test.main();
}

View file

@ -1,10 +1,19 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
import 'package:appflowy/startup/startup.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';
@ -175,5 +184,187 @@ void main() {
3,
);
});
testWidgets('toolbar will not rebuild after click item', (tester) async {
const text = 'Test rebuilding';
await prepareForToolbar(tester, text);
Finder toolbar = find.byType(DesktopFloatingToolbar);
Element toolbarElement = toolbar.evaluate().first;
final elementHashcode = toolbarElement.hashCode;
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
/// tap format buttons
await tester.tapButton(boldButton);
await tester.tapButton(underlineButton);
await tester.tapButton(italicButton);
toolbar = find.byType(DesktopFloatingToolbar);
toolbarElement = toolbar.evaluate().first;
/// check if the toolbar is not rebuilt
expect(elementHashcode, toolbarElement.hashCode);
final editorState = tester.editor.getCurrentEditorState();
/// check text formats
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
true,
);
});
});
group('document toolbar: link', () {
String? getLinkFromNode(Node node) {
for (final insert in node.delta!) {
final link = insert.attributes?.href;
if (link != null) return link;
}
return null;
}
bool isPageLink(Node node) {
for (final insert in node.delta!) {
final isPage = insert.attributes?.isPage;
if (isPage == true) return true;
}
return false;
}
String getNodeText(Node node) {
for (final insert in node.delta!) {
if (insert is TextInsert) return insert.text;
}
return '';
}
testWidgets('insert link and remove link', (tester) async {
const text = 'insert link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
final toolbar = find.byType(DesktopFloatingToolbar);
expect(toolbar, findsOneWidget);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
final createLinkMenu = find.byType(LinkCreateMenu);
expect(createLinkMenu, findsOneWidget);
/// test esc to close
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
expect(toolbar, findsNothing);
/// show toolbar again
await tester.editor.tapLineOfEditorAt(0);
await selectText(tester, text);
await tester.tapButton(linkButton);
/// insert link
final textField = find.descendant(
of: createLinkMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(textField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
Node node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), link);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
final hoverMenu = find.byType(LinkHoverMenu);
expect(hoverMenu, findsOneWidget);
/// copy link
final copyButton = find.descendant(
of: hoverMenu,
matching: find.byFlowySvg(FlowySvgs.toolbar_link_m),
);
await tester.tapButton(copyButton);
final clipboardContent = await getIt<ClipboardService>().getData();
final plainText = clipboardContent.plainText;
expect(plainText, link);
/// remove link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), null);
});
testWidgets('insert link and edit link', (tester) async {
const text = 'edit link',
link = 'https://test.appflowy.cloud',
afterText = '$text after';
await prepareForToolbar(tester, text);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// search for page and select it
final textField = find.descendant(
of: find.byType(LinkCreateMenu),
matching: find.byType(TextFormField),
);
await tester.enterText(textField, gettingStarted);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
Node node = tester.editor.getNodeAtPath([0]);
expect(isPageLink(node), true);
expect(getLinkFromNode(node) == link, false);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
/// click edit button to show LinkEditMenu
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
await tester.tapButton(editButton);
final linkEditMenu = find.byType(LinkEditMenu);
expect(linkEditMenu, findsOneWidget);
/// change the link text
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, afterText);
await tester.pumpAndSettle();
await tester.tapButton(
find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)),
);
final linkField = find.ancestor(
of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()),
matching: find.byType(TextFormField),
);
await tester.enterText(linkField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
/// apply the change
final applyButton =
find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr());
await tester.tapButton(applyButton);
node = tester.editor.getNodeAtPath([0]);
expect(isPageLink(node), false);
expect(getLinkFromNode(node), link);
expect(getNodeText(node), afterText);
});
});
}

View file

@ -1,8 +1,10 @@
import 'dart:io';
import 'package:appflowy/plugins/emoji/emoji_handler.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@ -39,4 +41,110 @@ void main() {
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
});
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(
WidgetTester tester, {
String? search,
}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(':${search ?? 'a'}');
await tester.pumpAndSettle(Duration(seconds: 1));
}
testWidgets('insert with click', (tester) async {
await createNewDocumentAndShowEmojiList(tester);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
final emojiButtons =
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
final firstTextFinder = find.descendant(
of: emojiButtons.first,
matching: find.byType(FlowyText),
);
final emojiText =
(firstTextFinder.evaluate().first.widget as FlowyText).text;
/// click first emoji item
await tester.tapButton(emojiButtons.first);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
});
testWidgets('insert with arrow and enter', (tester) async {
await createNewDocumentAndShowEmojiList(tester);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
final emojiButtons =
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
/// tap arrow down and arrow up
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown);
final firstTextFinder = find.descendant(
of: emojiButtons.first,
matching: find.byType(FlowyText),
);
final emojiText =
(firstTextFinder.evaluate().first.widget as FlowyText).text;
/// tap enter
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
});
testWidgets('insert with searching', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: 's');
/// search for `smiling eyes`, IME is not working, use keyboard input
final searchText = [
LogicalKeyboardKey.keyM,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyL,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyN,
LogicalKeyboardKey.keyG,
LogicalKeyboardKey.space,
LogicalKeyboardKey.keyE,
LogicalKeyboardKey.keyY,
LogicalKeyboardKey.keyE,
LogicalKeyboardKey.keyS,
];
for (final key in searchText) {
await tester.simulateKeyEvent(key);
}
/// tap enter
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(firstNode.delta!.toPlainText().contains('😄'), true);
});
testWidgets('start searching with sapce', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: ' ');
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsNothing);
});
});
}

View file

@ -13,7 +13,6 @@ void main() {
hotkeys_test.main();
emoji_shortcut_test.main();
hotkeys_test.main();
emoji_shortcut_test.main();
share_markdown_test.main();
import_files_test.main();
zoom_in_out_test.main();

View file

@ -67,12 +67,10 @@ extension CommonOperations on WidgetTester {
} else {
// cloud version
final anonymousButton = find.byType(SignInAnonymousButtonV2);
await tapButton(anonymousButton);
await tapButton(anonymousButton, warnIfMissed: true);
}
if (Platform.isWindows) {
await pumpAndSettle(const Duration(milliseconds: 200));
}
await pumpAndSettle(const Duration(milliseconds: 200));
}
Future<void> tapContinousAnotherWay() async {

View file

@ -942,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
await pumpAndSettle(const Duration(milliseconds: 200));
}
Future<void> changeFieldWidth(String fieldName, double width) async {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
await hoverOnWidget(
field,
onHover: () async {
final dragHandle = find.descendant(
of: field,
matching: find.byType(DragToExpandLine),
);
await drag(dragHandle, Offset(width - getSize(field).width, 0));
await pumpAndSettle(const Duration(milliseconds: 200));
},
);
}
double getFieldWidth(String fieldName) {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
return getSize(field).width;
}
Future<void> findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);

View file

@ -307,9 +307,11 @@ class EditorOperations {
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
find
.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
)
.first,
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}

View file

@ -79,7 +79,7 @@ extension AppFlowySettings on WidgetTester {
// Enable editing username
final editUsernameFinder = find.descendant(
of: find.byType(AccountUserProfile),
matching: find.byFlowySvg(FlowySvgs.edit_s),
matching: find.byFlowySvg(FlowySvgs.toolbar_link_edit_m),
);
await tap(editUsernameFinder, warnIfMissed: false);
await pumpAndSettle();

View file

@ -181,37 +181,37 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca

View file

@ -2,6 +2,8 @@ export 'service/ai_entities.dart';
export 'service/ai_prompt_input_bloc.dart';
export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'service/ai_model_state_notifier.dart';
export 'service/select_model_bloc.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_prompt_text_field.dart';
@ -13,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/predefined_format_buttons.dart';
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
export 'widgets/prompt_input/select_sources_menu.dart';
export 'widgets/prompt_input/select_model_menu.dart';
export 'widgets/prompt_input/send_button.dart';

View file

@ -4,6 +4,28 @@ import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
class AIStreamEventPrefix {
static const data = 'data:';
static const error = 'error:';
static const metadata = 'metadata:';
static const start = 'start:';
static const finish = 'finish:';
static const comment = 'comment:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
static const localAIDisabled = 'LOCAL_AI_DISABLED';
}
enum AiType {
cloud,
local;
bool get isCloud => this == cloud;
bool get isLocal => this == local;
}
class PredefinedFormat extends Equatable {
const PredefinedFormat({
required this.imageFormat,

View file

@ -0,0 +1,181 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
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_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:protobuf/protobuf.dart';
import 'package:universal_platform/universal_platform.dart';
typedef OnModelStateChangedCallback = void Function(AiType, bool, String);
typedef OnAvailableModelsChangedCallback = void Function(
List<AIModelPB>,
AIModelPB?,
);
class AIModelStateNotifier {
AIModelStateNotifier({required this.objectId})
: _localAIListener =
UniversalPlatform.isDesktop ? LocalAIStateListener() : null,
_aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) {
_startListening();
_init();
}
final String objectId;
final LocalAIStateListener? _localAIListener;
final AIModelSwitchListener _aiModelSwitchListener;
LocalAIPB? _localAIState;
AvailableModelsPB? _availableModels;
// callbacks
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
final List<OnAvailableModelsChangedCallback>
_availableModelsChangedCallbacks = [];
void _startListening() {
if (UniversalPlatform.isDesktop) {
_localAIListener?.start(
stateCallback: (state) async {
_localAIState = state;
_notifyStateChanged();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadAvailableModels();
_notifyAvailableModelsChanged();
}
},
);
}
_aiModelSwitchListener.start(
onUpdateSelectedModel: (model) async {
final updatedModels = _availableModels?.deepCopy()
?..selectedModel = model;
_availableModels = updatedModels;
_notifyAvailableModelsChanged();
if (model.isLocal && UniversalPlatform.isDesktop) {
await _loadLocalAiState();
}
_notifyStateChanged();
},
);
}
void _init() async {
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
_notifyStateChanged();
_notifyAvailableModelsChanged();
}
void addListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
}) {
if (onStateChanged != null) {
_stateChangedCallbacks.add(onStateChanged);
}
if (onAvailableModelsChanged != null) {
_availableModelsChangedCallbacks.add(onAvailableModelsChanged);
}
}
void removeListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
}) {
if (onStateChanged != null) {
_stateChangedCallbacks.remove(onStateChanged);
}
if (onAvailableModelsChanged != null) {
_availableModelsChangedCallbacks.remove(onAvailableModelsChanged);
}
}
Future<void> dispose() async {
_stateChangedCallbacks.clear();
_availableModelsChangedCallbacks.clear();
await _localAIListener?.stop();
await _aiModelSwitchListener.stop();
}
(AiType, String, bool) getState() {
if (UniversalPlatform.isMobile) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
final availableModels = _availableModels;
final localAiState = _localAIState;
if (availableModels == null) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
if (localAiState == null) {
Log.warn("Cannot get local AI state");
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
if (!availableModels.selectedModel.isLocal) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
final editable = localAiState.state == RunningStatePB.Running;
final hintText = editable
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
return (AiType.local, hintText, editable);
}
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
final availableModels = _availableModels;
if (availableModels == null) {
return ([], null);
}
return (availableModels.models, availableModels.selectedModel);
}
void _notifyAvailableModelsChanged() {
final (models, selectedModel) = getAvailableModels();
for (final callback in _availableModelsChangedCallbacks) {
callback(models, selectedModel);
}
}
void _notifyStateChanged() {
final (type, hintText, isEditable) = getState();
for (final callback in _stateChangedCallbacks) {
callback(type, isEditable, hintText);
}
}
Future<void> _loadAvailableModels() {
final payload = AvailableModelsQueryPB(source: objectId);
return AIEventGetAvailableModels(payload).send().fold(
(models) => _availableModels = models,
(err) => Log.error("Failed to get available models: $err"),
);
}
Future<void> _loadLocalAiState() {
return AIEventGetLocalAIState().send().fold(
(localAIState) => _localAIState = localAIState,
(error) => Log.error("Failed to get local AI state: $error"),
);
}
}
extension AiModelExtension on AIModelPB {
bool get isDefault {
return name == "Auto";
}
String get i18n {
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
}
}

View file

@ -1,14 +1,8 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
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:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -18,19 +12,20 @@ part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc({
required String objectId,
required PredefinedFormat? predefinedFormat,
}) : _listener = LocalAIStateListener(),
}) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch();
_startListening();
_init();
}
final LocalAIStateListener _listener;
final AIModelStateNotifier aiModelStateNotifier;
@override
Future<void> close() async {
await _listener.stop();
await aiModelStateNotifier.dispose();
return super.close();
}
@ -38,29 +33,10 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
updateAIState: (localAIState) {
final aiType = localAIState.enabled ? AiType.local : AiType.cloud;
// final supportChatWithFile =
// aiType.isLocal && localAIState.state == RunningStatePB.Running;
// If local ai is enabled, user can only send messages when the AI is running
final editable = localAIState.enabled
? localAIState.state == RunningStatePB.Running
: true;
var hintText = aiType.isLocal
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.chat_inputMessageHint.tr();
if (editable == false && aiType.isLocal) {
hintText =
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
}
updateAIState: (aiType, editable, hintText) {
emit(
state.copyWith(
aiType: aiType,
supportChatWithFile: false,
localAIState: localAIState,
editable: editable,
hintText: hintText,
),
@ -128,24 +104,16 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
}
void _startListening() {
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(pluginState));
}
aiModelStateNotifier.addListener(
onStateChanged: (aiType, editable, hintText) {
add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
},
);
}
void _init() {
AIEventGetLocalAIState().send().fold(
(localAIState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(localAIState));
}
},
Log.error,
);
final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
}
Map<String, dynamic> consumeMetadata() {
@ -164,8 +132,12 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateAIState(LocalAIPB localAIState) =
_UpdateAIState;
const factory AIPromptInputEvent.updateAIState(
AiType aiType,
bool editable,
String hintText,
) = _UpdateAIState;
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
_ToggleShowPredefinedFormat;
const factory AIPromptInputEvent.updatePredefinedFormat(
@ -188,7 +160,6 @@ class AIPromptInputState with _$AIPromptInputState {
required bool supportChatWithFile,
required bool showPredefinedFormats,
required PredefinedFormat? predefinedFormat,
required LocalAIPB? localAIState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
required bool editable,
@ -201,18 +172,9 @@ class AIPromptInputState with _$AIPromptInputState {
supportChatWithFile: false,
showPredefinedFormats: format != null,
predefinedFormat: format,
localAIState: null,
attachedFiles: [],
mentionedPages: [],
editable: true,
hintText: '',
);
}
enum AiType {
cloud,
local;
bool get isCloud => this == cloud;
bool get isLocal => this == local;
}

View file

@ -15,6 +15,11 @@ import 'package:fixnum/fixnum.dart' as fixnum;
import 'ai_entities.dart';
import 'error.dart';
enum LocalAIStreamingState {
notReady,
disabled,
}
abstract class AIRepository {
Future<void> streamCompletion({
String? objectId,
@ -24,9 +29,12 @@ abstract class AIRepository {
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function(String text) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
});
}
@ -40,15 +48,20 @@ class AppFlowyAIService implements AIRepository {
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function(String text) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
}) async {
final stream = AppFlowyCompletionStream(
onStart: onStart,
onProcess: onProcess,
processMessage: processMessage,
processAssistMessage: processAssistMessage,
processError: onError,
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
onEnd: onEnd,
onError: onError,
);
final records = history.map((record) => record.toPB()).toList();
@ -79,23 +92,30 @@ class AppFlowyAIService implements AIRepository {
abstract class CompletionStream {
CompletionStream({
required this.onStart,
required this.onProcess,
required this.processMessage,
required this.processAssistMessage,
required this.processError,
required this.onLocalAIStreamingStateChange,
required this.onEnd,
required this.onError,
});
final Future<void> Function() onStart;
final Future<void> Function(String text) onProcess;
final Future<void> Function(String text) processMessage;
final Future<void> Function(String text) processAssistMessage;
final void Function(AIError error) processError;
final void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange;
final Future<void> Function() onEnd;
final void Function(AIError error) onError;
}
class AppFlowyCompletionStream extends CompletionStream {
AppFlowyCompletionStream({
required super.onStart,
required super.onProcess,
required super.processMessage,
required super.processAssistMessage,
required super.processError,
required super.onEnd,
required super.onError,
required super.onLocalAIStreamingStateChange,
}) {
_startListening();
}
@ -109,51 +129,7 @@ class AppFlowyCompletionStream extends CompletionStream {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
if (event == "AI_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
}
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
code: AIErrorCode.aiImageResponseLimitExceeded,
),
);
}
if (event.startsWith("AI_MAX_REQUIRED:")) {
final msg = event.substring(16);
onError(
AIError(
message: msg,
code: AIErrorCode.other,
),
);
}
if (event.startsWith("start:")) {
await onStart();
}
if (event.startsWith("data:")) {
await onProcess(event.substring(5));
}
if (event.startsWith("finish:")) {
await onEnd();
}
if (event.startsWith("error:")) {
onError(
AIError(message: event.substring(6), code: AIErrorCode.other),
);
}
await _handleEvent(event);
},
);
}
@ -163,4 +139,66 @@ class AppFlowyCompletionStream extends CompletionStream {
await _subscription.cancel();
_port.close();
}
Future<void> _handleEvent(String event) async {
// Check simple matches first
if (event == AIStreamEventPrefix.aiResponseLimit) {
processError(
AIError(
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
return;
}
if (event == AIStreamEventPrefix.aiImageResponseLimit) {
processError(
AIError(
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
code: AIErrorCode.aiImageResponseLimitExceeded,
),
);
return;
}
// Otherwise, parse out prefix:content
if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
processError(
AIError(
message: event.substring(AIStreamEventPrefix.aiMaxRequired.length),
code: AIErrorCode.other,
),
);
} else if (event.startsWith(AIStreamEventPrefix.start)) {
await onStart();
} else if (event.startsWith(AIStreamEventPrefix.data)) {
await processMessage(
event.substring(AIStreamEventPrefix.data.length),
);
} else if (event.startsWith(AIStreamEventPrefix.comment)) {
await processAssistMessage(
event.substring(AIStreamEventPrefix.comment.length),
);
} else if (event.startsWith(AIStreamEventPrefix.finish)) {
await onEnd();
} else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) {
onLocalAIStreamingStateChange(
LocalAIStreamingState.disabled,
);
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
onLocalAIStreamingStateChange(
LocalAIStreamingState.notReady,
);
} else if (event.startsWith(AIStreamEventPrefix.error)) {
processError(
AIError(
message: event.substring(AIStreamEventPrefix.error.length),
code: AIErrorCode.other,
),
);
} else {
Log.debug('Unknown AI event: $event');
}
}
}

View file

@ -0,0 +1,92 @@
import 'dart:async';
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'select_model_bloc.freezed.dart';
class SelectModelBloc extends Bloc<SelectModelEvent, SelectModelState> {
SelectModelBloc({
required AIModelStateNotifier aiModelStateNotifier,
}) : _aiModelStateNotifier = aiModelStateNotifier,
super(SelectModelState.initial(aiModelStateNotifier)) {
on<SelectModelEvent>(
(event, emit) {
event.when(
selectModel: (model) {
AIEventUpdateSelectedModel(
UpdateSelectedModelPB(
source: _aiModelStateNotifier.objectId,
selectedModel: model,
),
).send();
emit(state.copyWith(selectedModel: model));
},
didLoadModels: (models, selectedModel) {
emit(
SelectModelState(
models: models,
selectedModel: selectedModel,
),
);
},
);
},
);
_aiModelStateNotifier.addListener(
onAvailableModelsChanged: _onAvailableModelsChanged,
);
}
final AIModelStateNotifier _aiModelStateNotifier;
@override
Future<void> close() async {
_aiModelStateNotifier.removeListener(
onAvailableModelsChanged: _onAvailableModelsChanged,
);
await super.close();
}
void _onAvailableModelsChanged(
List<AIModelPB> models,
AIModelPB? selectedModel,
) {
if (!isClosed) {
add(SelectModelEvent.didLoadModels(models, selectedModel));
}
}
}
@freezed
class SelectModelEvent with _$SelectModelEvent {
const factory SelectModelEvent.selectModel(
AIModelPB model,
) = _SelectModel;
const factory SelectModelEvent.didLoadModels(
List<AIModelPB> models,
AIModelPB? selectedModel,
) = _DidLoadModels;
}
@freezed
class SelectModelState with _$SelectModelState {
const factory SelectModelState({
required List<AIModelPB> models,
required AIModelPB? selectedModel,
}) = _SelectModelState;
factory SelectModelState.initial(AIModelStateNotifier notifier) {
final (models, selectedModel) = notifier.getAvailableModels();
return SelectModelState(
models: models,
selectedModel: selectedModel,
);
}
}

View file

@ -17,20 +17,26 @@ class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
required this.isStreaming,
required this.textController,
required this.onStopStreaming,
required this.onSubmitted,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
this.hideFormats = false,
this.extraBottomActionButton,
});
final bool isStreaming;
final TextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
final bool hideFormats;
final Widget? extraBottomActionButton;
@override
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
@ -42,7 +48,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
late SendButtonState sendButtonState;
bool isComposing = false;
@ -51,17 +56,19 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChanged);
focusNode.addListener(
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
);
widget.textController.addListener(handleTextControllerChanged);
focusNode
..addListener(
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
)
..onKeyEvent = handleKeyEvent;
updateSendButtonState();
@ -79,7 +86,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void dispose() {
focusNode.dispose();
textController.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
super.dispose();
}
@ -104,7 +111,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
overlayChildBuilder: (context) {
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
textController: textController,
textController: widget.textController,
onPageSelected: handlePageSelected,
);
},
@ -134,11 +141,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats,
state.showPredefinedFormats && !widget.hideFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats)
if (state.showPredefinedFormats && !widget.hideFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
@ -163,8 +170,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormats:
showPredefinedFormatBar:
state.showPredefinedFormats,
showPredefinedFormatButton: !widget.hideFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
@ -178,6 +186,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
widget.selectedSourcesNotifier,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
extraBottomActionButton:
widget.extraBottomActionButton,
),
),
),
@ -216,12 +226,12 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
if (!focusNode.hasFocus) {
focusNode.requestFocus();
}
textController.text += '@';
widget.textController.text += '@';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context
.read<ChatInputControlCubit>()
.startSearching(textController.value);
.startSearching(widget.textController.value);
overlayController.show();
}
});
@ -237,7 +247,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (textController.text.trim().isEmpty) {
} else if (widget.textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
@ -249,9 +259,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
widget.textController.text.trim(),
);
textController.clear();
widget.textController.clear();
if (trimmedText.isEmpty) {
return;
}
@ -274,7 +284,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
setState(() {
// update whether send button is clickable
updateSendButtonState();
isComposing = !textController.value.composing.isCollapsed;
isComposing = !widget.textController.value.composing.isCollapsed;
});
if (isComposing) {
@ -292,6 +302,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
}
// handle cases where mention a page is cancelled
final textController = widget.textController;
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
@ -338,22 +349,27 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
if (event.character == '@') {
WidgetsBinding.instance.addPostFrameCallback((_) {
inputControlCubit.startSearching(textController.value);
overlayController.show();
});
// if (event.character == '@') {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// inputControlCubit.startSearching(widget.textController.value);
// overlayController.show();
// });
// }
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void handlePageSelected(ViewPB view) {
final newText = textController.text.replaceRange(
final newText = widget.textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
view.id,
);
textController.value = TextEditingValue(
widget.textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: inputControlCubit.filterStartPosition + view.id.length,
@ -378,7 +394,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
key: textFieldKey,
editable: state.editable,
cubit: inputControlCubit,
textController: textController,
textController: widget.textController,
textFieldFocusNode: focusNode,
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
@ -558,16 +574,19 @@ class PromptInputTextField extends StatelessWidget {
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.sendButtonState,
required this.showPredefinedFormats,
required this.showPredefinedFormatBar,
required this.showPredefinedFormatButton,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
required this.onStopStreaming,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.extraBottomActionButton,
});
final bool showPredefinedFormats;
final bool showPredefinedFormatBar;
final bool showPredefinedFormatButton;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
@ -575,6 +594,7 @@ class _PromptBottomActions extends StatelessWidget {
final void Function() onStopStreaming;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final Widget? extraBottomActionButton;
@override
Widget build(BuildContext context) {
@ -583,18 +603,27 @@ class _PromptBottomActions extends StatelessWidget {
margin: DesktopAIChatSizes.inputActionBarMargin,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
if (state.localAIState == null) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _sendButton(),
);
}
return Row(
children: [
_predefinedFormatButton(),
if (showPredefinedFormatButton) ...[
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
),
const Spacer(),
if (state.aiType.isCloud) ...[
_selectSourcesButton(context),
_selectSourcesButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (extraBottomActionButton != null) ...[
extraBottomActionButton!,
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
@ -619,12 +648,12 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
showFormatBar: showPredefinedFormatBar,
onTap: onTogglePredefinedFormatSection,
);
}
Widget _selectSourcesButton(BuildContext context) {
Widget _selectSourcesButton() {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
selectedSourcesNotifier: selectedSourcesNotifier,

View file

@ -104,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@ -150,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(

View file

@ -0,0 +1,264 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.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';
class SelectModelMenu extends StatefulWidget {
const SelectModelMenu({
super.key,
required this.aiModelStateNotifier,
});
final AIModelStateNotifier aiModelStateNotifier;
@override
State<SelectModelMenu> createState() => _SelectModelMenuState();
}
class _SelectModelMenuState extends State<SelectModelMenu> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SelectModelBloc(
aiModelStateNotifier: widget.aiModelStateNotifier,
),
child: BlocBuilder<SelectModelBloc, SelectModelState>(
builder: (context, state) {
return AppFlowyPopover(
offset: Offset(-12.0, 0.0),
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
direction: PopoverDirection.topWithLeftAligned,
margin: EdgeInsets.zero,
controller: popoverController,
popupBuilder: (popoverContext) {
return SelectModelPopoverContent(
models: state.models,
selectedModel: state.selectedModel,
onSelectModel: (model) {
if (model != state.selectedModel) {
context
.read<SelectModelBloc>()
.add(SelectModelEvent.selectModel(model));
}
popoverController.close();
},
);
},
child: _CurrentModelButton(
model: state.selectedModel,
onTap: () {
if (state.selectedModel != null) {
popoverController.show();
}
},
),
);
},
),
);
}
}
class SelectModelPopoverContent extends StatelessWidget {
const SelectModelPopoverContent({
super.key,
required this.models,
required this.selectedModel,
this.onSelectModel,
});
final List<AIModelPB> models;
final AIModelPB? selectedModel;
final void Function(AIModelPB)? onSelectModel;
@override
Widget build(BuildContext context) {
if (models.isEmpty) {
return const SizedBox.shrink();
}
// separate models into local and cloud models
final localModels = models.where((model) => model.isLocal).toList();
final cloudModels = models.where((model) => !model.isLocal).toList();
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (localModels.isNotEmpty) ...[
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_localModel.tr(),
),
const VSpace(4.0),
],
...localModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
const VSpace(8.0),
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
),
const VSpace(4.0),
],
...cloudModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
],
),
);
}
}
class _ModelSectionHeader extends StatelessWidget {
const _ModelSectionHeader({
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: FlowyText(
title,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
fontWeight: FontWeight.w500,
),
);
}
}
class _ModelItem extends StatelessWidget {
const _ModelItem({
required this.model,
required this.isSelected,
required this.onTap,
});
final AIModelPB model;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32),
child: FlowyButton(
onTap: onTap,
margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
text: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
model.i18n,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
if (model.desc.isNotEmpty)
FlowyText(
model.desc,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
],
),
rightIcon: isSelected
? FlowySvg(
FlowySvgs.check_s,
size: const Size.square(20),
color: Theme.of(context).colorScheme.primary,
)
: null,
),
);
}
}
class _CurrentModelButton extends StatelessWidget {
const _CurrentModelButton({
required this.model,
required this.onTap,
});
final AIModelPB? model;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_switchModel_label.tr(),
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: AnimatedSize(
duration: const Duration(milliseconds: 50),
curve: Curves.easeInOut,
alignment: AlignmentDirectional.centerStart,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Row(
children: [
Padding(
// TODO: remove this after change icon to 20px
padding: EdgeInsets.all(2),
child: FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: Size.square(16),
),
),
if (model != null && !model!.isDefault)
Padding(
padding: EdgeInsetsDirectional.only(end: 2.0),
child: FlowyText(
model!.i18n,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
),
),
),
),
),
);
}
}

View file

@ -145,7 +145,7 @@ class _IndicatorButton extends StatelessWidget {
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
color: Theme.of(context).hintColor,
),
const HSpace(2.0),
ValueListenableBuilder(
@ -170,7 +170,7 @@ class _IndicatorButton extends StatelessWidget {
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
size: const Size.square(8),
),
],
),

View file

@ -45,16 +45,18 @@ Future<bool> afLaunchUri(
}
// try to launch the uri directly
bool result;
try {
result = await launcher.launchUrl(
uri,
mode: mode,
webOnlyWindowName: webOnlyWindowName,
);
} on PlatformException catch (e) {
Log.error('Failed to open uri: $e');
return false;
bool result = await launcher.canLaunchUrl(uri);
if (result) {
try {
result = await launcher.launchUrl(
uri,
mode: mode,
webOnlyWindowName: webOnlyWindowName,
);
} on PlatformException catch (e) {
Log.error('Failed to open uri: $e');
return false;
}
}
// if the uri is not a valid url, try to launch it with http scheme
@ -133,7 +135,6 @@ Future<bool> _afLaunchLocalUri(
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success

View file

@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
typedef CompareFunction<T> = bool Function(T? left, T? right);
// Navigation shortcuts to move the selected menu items up or down.
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
<ShortcutActivator, Intent>{
@ -86,6 +88,7 @@ class AFDropdownMenu<T> extends StatefulWidget {
this.requestFocusOnTap,
this.expandedInsets,
this.searchCallback,
this.selectOptionCompare,
required this.dropdownMenuEntries,
});
@ -267,6 +270,11 @@ class AFDropdownMenu<T> extends StatefulWidget {
/// which contains the contents of the text input field.
final SearchCallback<T>? searchCallback;
/// Defines the compare function for the menu items.
///
/// Defaults to null. If this is null, the menu items will be sorted by the label.
final CompareFunction<T>? selectOptionCompare;
@override
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
}
@ -301,7 +309,16 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
(DropdownMenuEntry<T> entry) {
if (widget.selectOptionCompare != null) {
return widget.selectOptionCompare!(
entry.value,
widget.initialSelection,
);
} else {
return entry.value == widget.initialSelection;
}
},
);
if (index != -1) {
_textEditingController.value = TextEditingValue(

View file

@ -19,14 +19,13 @@ class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> {
Future<void> _initialize(Emitter<UserProfileState> emit) async {
emit(const UserProfileState.loading());
final workspaceOrFailure =
final latestOrFailure =
await FolderEventGetCurrentWorkspaceSetting().send();
final userOrFailure = await getIt<AuthService>().getUser();
final workspaceSetting = workspaceOrFailure.fold(
(workspaceSettingPB) => workspaceSettingPB,
final latest = latestOrFailure.fold(
(latestPB) => latestPB,
(error) => null,
);
@ -35,13 +34,13 @@ class UserProfileBloc extends Bloc<UserProfileEvent, UserProfileState> {
(error) => null,
);
if (workspaceSetting == null || userProfile == null) {
if (latest == null || userProfile == null) {
return emit(const UserProfileState.workspaceFailure());
}
emit(
UserProfileState.success(
workspaceSettings: workspaceSetting,
workspaceSettings: latest,
userProfile: userProfile,
),
);
@ -59,7 +58,7 @@ class UserProfileState with _$UserProfileState {
const factory UserProfileState.loading() = _Loading;
const factory UserProfileState.workspaceFailure() = _WorkspaceFailure;
const factory UserProfileState.success({
required WorkspaceSettingPB workspaceSettings,
required WorkspaceLatestPB workspaceSettings,
required UserProfilePB userProfile,
}) = _Success;
}

View file

@ -336,7 +336,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
@ -366,7 +365,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}

View file

@ -66,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
break;
case MobileViewBottomSheetBodyAction.delete:
context.read<ViewBloc>().add(const ViewEvent.delete());
context.pop();
Navigator.of(context).pop();
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
_addFavorite(context);
@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.pop();
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
}
@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
}
@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
}
@ -202,8 +199,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
}
@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
} else {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
);
@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
state.updatePathNameResult!.onSuccess(
(value) {
showToastNotification(
context,
message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);

View file

@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
context.read<ViewBloc>().add(const ViewEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
break;
@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view));
showToastNotification(
context,
message: !widget.view.isFavorite
? LocaleKeys.button_favoriteSuccessfully.tr()
: LocaleKeys.button_unfavoriteSuccessfully.tr(),
@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},

View file

@ -182,7 +182,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
@ -202,8 +202,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
List<Widget> _buildPublishActions(BuildContext context) {
final userProfile = context.read<MobileViewPageBloc>().state.userProfilePB;
// the publish feature is only available for AppFlowy Cloud
if (userProfile == null ||
userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) {
if (userProfile == null || userProfile.authType != AuthTypePB.Server) {
return [];
}
@ -236,6 +235,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.unpublish,
),
),
_divider(),
];
} else {
return [
@ -246,6 +246,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.publish,
),
),
_divider(),
];
}
}

View file

@ -45,7 +45,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
@ -61,7 +60,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);

View file

@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
Log.info('Open row page(${widget.documentId})');
if (view == null) {
showToastNotification(context, message: 'Failed to open row page');
showToastNotification(message: 'Failed to open row page');
// reload the view again
unawaited(_preloadView(context));
Log.error('Failed to open row page(${widget.documentId})');

View file

@ -31,9 +31,9 @@ class MobileFavoriteScreen extends StatelessWidget {
return const Center(child: CircularProgressIndicator.adaptive());
}
final workspaceSetting = snapshots.data?[0].fold(
(workspaceSettingPB) {
return workspaceSettingPB as WorkspaceSettingPB?;
final latest = snapshots.data?[0].fold(
(latest) {
return latest as WorkspaceLatestPB?;
},
(error) => null,
);
@ -46,7 +46,7 @@ class MobileFavoriteScreen extends StatelessWidget {
// In the unlikely case either of the above is null, eg.
// when a workspace is already open this can happen.
if (workspaceSetting == null || userProfile == null) {
if (latest == null || userProfile == null) {
return const WorkspaceFailedScreen();
}

View file

@ -44,9 +44,9 @@ class MobileHomeScreen extends StatelessWidget {
return const Center(child: CircularProgressIndicator.adaptive());
}
final workspaceSetting = snapshots.data?[0].fold(
(workspaceSettingPB) {
return workspaceSettingPB as WorkspaceSettingPB?;
final workspaceLatest = snapshots.data?[0].fold(
(workspaceLatestPB) {
return workspaceLatestPB as WorkspaceLatestPB?;
},
(error) => null,
);
@ -59,7 +59,7 @@ class MobileHomeScreen extends StatelessWidget {
// In the unlikely case either of the above is null, eg.
// when a workspace is already open this can happen.
if (workspaceSetting == null || userProfile == null) {
if (workspaceLatest == null || userProfile == null) {
return const WorkspaceFailedScreen();
}
@ -78,7 +78,7 @@ class MobileHomeScreen extends StatelessWidget {
value: userProfile,
child: MobileHomePage(
userProfile: userProfile,
workspaceSetting: workspaceSetting,
workspaceLatest: workspaceLatest,
),
),
),
@ -95,11 +95,11 @@ class MobileHomePage extends StatefulWidget {
const MobileHomePage({
super.key,
required this.userProfile,
required this.workspaceSetting,
required this.workspaceLatest,
});
final UserProfilePB userProfile;
final WorkspaceSettingPB workspaceSetting;
final WorkspaceLatestPB workspaceLatest;
@override
State<MobileHomePage> createState() => _MobileHomePageState();
@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> {
}
if (message != null) {
showToastNotification(context, message: message, type: toastType);
showToastNotification(message: message, type: toastType);
}
}
}

View file

@ -194,6 +194,7 @@ class _MobileWorkspace extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.openWorkspace(
workspace.workspaceId,
workspace.workspaceAuthType,
),
);
},

View file

@ -71,12 +71,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
}
Widget _buildSettingsWidget(UserProfilePB userProfile) {
// show the third-party sign in buttons if user logged in with local session and auth is enabled.
final isLocalAuthEnabled =
userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled;
'';
return BlocProvider(
create: (context) => UserWorkspaceBloc(userProfile: userProfile)
..add(const UserWorkspaceEvent.initial()),
@ -100,13 +94,12 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
key: ValueKey(currentWorkspaceId),
userProfile: userProfile,
workspaceId: currentWorkspaceId,
currentWorkspaceMemberRole: state.currentWorkspace?.role,
),
const SupportSettingGroup(),
const AboutSettingGroup(),
UserSessionSettingGroup(
userProfile: userProfile,
showThirdPartyLogin: isLocalAuthEnabled,
showThirdPartyLogin: false,
),
const VSpace(20),
],

View file

@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem {
members,
trash,
help,
helpAndDocumentation,
}
class HomePageSettingsPopupMenu extends StatelessWidget {
@ -47,7 +48,7 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_settings.tr(),
),
// only show the member items in cloud mode
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[
if (userProfile.authType == AuthTypePB.Server) ...[
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.members,
@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
svg: FlowySvgs.help_and_documentation_s,
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.help,
svg: FlowySvgs.message_support_s,
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
),
],
onSelected: (_MobileSettingsPopupMenuItem value) {
@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
case _MobileSettingsPopupMenuItem.help:
_openHelpPage(context);
break;
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
_openHelpAndDocumentationPage(context);
break;
}
},
child: const Padding(
@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
void _openSettingsPage(BuildContext context) {
context.push(MobileHomeSettingPage.routeName);
}
void _openHelpAndDocumentationPage(BuildContext context) {
afLaunchUrlString('https://appflowy.com/guide');
}
}
class _PopupButton extends StatelessWidget {

View file

@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.space_success_duplicateSpace.tr(),
);
@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
.add(SpaceEvent.rename(space: widget.space, name: name));
showToastNotification(
context,
message: LocaleKeys.space_success_renameSpace.tr(),
);
},
@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
);
showToastNotification(
context,
message: LocaleKeys.space_success_updateSpace.tr(),
);
@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
showToastNotification(
context,
message: LocaleKeys.space_success_deleteSpace.tr(),
);

View file

@ -167,8 +167,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
children: [
MobileHomeSpace(userProfile: widget.userProfile),
// only show ai chat button for cloud user
if (widget.userProfile.authenticator ==
AuthenticatorPB.AppFlowyCloud)
if (widget.userProfile.authType == AuthTypePB.Server)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 20,

View file

@ -123,6 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.createWorkspace(
name,
AuthTypePB.Server,
),
);
},

View file

@ -102,7 +102,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasIcon = item.icon != null;
final hasIcon = item.iconBuilder != null;
return Container(
height: 36,
decoration: BoxDecoration(
@ -119,7 +119,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
child: Row(
children: [
if (hasIcon) ...[
item.icon!.call(isSelected),
item.iconBuilder!.call(isSelected),
SizedBox(width: 12),
],
Flexible(

View file

@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);

View file

@ -50,9 +50,9 @@ class _MobileNotificationsScreenState extends State<MobileNotificationsScreen>
orElse: () =>
const Center(child: CircularProgressIndicator.adaptive()),
workspaceFailure: () => const WorkspaceFailedScreen(),
success: (workspaceSetting, userProfile) =>
success: (workspaceLatest, userProfile) =>
_NotificationScreenContent(
workspaceSetting: workspaceSetting,
workspaceLatest: workspaceLatest,
userProfile: userProfile,
controller: controller,
reminderBloc: reminderBloc,
@ -66,13 +66,13 @@ class _MobileNotificationsScreenState extends State<MobileNotificationsScreen>
class _NotificationScreenContent extends StatelessWidget {
const _NotificationScreenContent({
required this.workspaceSetting,
required this.workspaceLatest,
required this.userProfile,
required this.controller,
required this.reminderBloc,
});
final WorkspaceSettingPB workspaceSetting;
final WorkspaceLatestPB workspaceLatest;
final UserProfilePB userProfile;
final TabController controller;
final ReminderBloc reminderBloc;
@ -84,7 +84,7 @@ class _NotificationScreenContent extends StatelessWidget {
..add(
SidebarSectionsEvent.initial(
userProfile,
workspaceSetting.workspaceId,
workspaceLatest.workspaceId,
),
),
child: BlocBuilder<SidebarSectionsBloc, SidebarSectionsState>(

View file

@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onMarkAllAsRead(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onArchiveAll(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
}
showToastNotification(
context,
message: 'Unarchive all success (Debug Mode)',
);

View file

@ -31,7 +31,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_success
.tr(),
@ -55,7 +54,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: 'Unarchive notification success',
);
@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget {
Navigator.of(context).pop();
showToastNotification(
context,
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
.tr(),
);
@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget {
void _onArchive(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_success
.tr()
.tr(),

View file

@ -74,7 +74,6 @@ class _NotificationTabState extends State<NotificationTab>
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
);
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -71,6 +70,7 @@ class MobileSelectionMenu extends SelectionMenuService {
final editorWidth = editorState.renderBox!.size.width;
_positionNotifier = ValueNotifier(position);
final showAtTop = position.top != null;
_selectionMenuEntry = OverlayEntry(
builder: (context) {
return SizedBox(
@ -94,6 +94,7 @@ class MobileSelectionMenu extends SelectionMenuService {
child: MobileSelectionMenuWidget(
selectionMenuStyle: style,
singleColumn: singleColumn,
showAtTop: showAtTop,
items: selectionMenuItems
..forEach((element) {
if (element is MobileSelectionMenuItem) {
@ -166,7 +167,8 @@ class MobileSelectionMenu extends SelectionMenuService {
if (selectionRects.isEmpty) {
return null;
}
calculateSelectionMenuOffset(selectionRects.first);
final screenSize = MediaQuery.of(context).size;
calculateSelectionMenuOffset(selectionRects.first, screenSize);
final (left, top, right, bottom) = getPosition();
return _Position(left, top, right, bottom);
}
@ -205,50 +207,65 @@ class MobileSelectionMenu extends SelectionMenuService {
return (left, top, right, bottom);
}
void calculateSelectionMenuOffset(Rect rect) {
void calculateSelectionMenuOffset(Rect rect, Size screenSize) {
// Workaround: We can customize the padding through the [EditorStyle],
// but the coordinates of overlay are not properly converted currently.
// Just subtract the padding here as a result.
const menuHeight = 192.0, menuWidth = 240.0 + 10;
const menuOffset = Offset(0, 10);
const menuHeight = 192.0, menuWidth = 240.0;
final editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final editorHeight = editorState.renderBox!.size.height;
final screenHeight = screenSize.height;
final editorWidth = editorState.renderBox!.size.width;
final rectHeight = rect.height;
// show below default
_alignment = Alignment.topLeft;
final bottomRight = rect.bottomRight;
final topRight = rect.topRight;
var offset = bottomRight + menuOffset;
final limitX = editorWidth - menuWidth + editorOffset.dx;
_alignment = Alignment.bottomRight;
final bottomRight = rect.topLeft;
final offset = bottomRight;
final limitX = editorWidth + editorOffset.dx - menuWidth,
limitY = screenHeight -
editorHeight +
editorOffset.dy -
menuHeight -
rectHeight;
_offset = Offset(
min(offset.dx, limitX),
offset.dy,
editorWidth - offset.dx - menuWidth,
screenHeight - offset.dy - menuHeight - rectHeight,
);
// show above
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
offset = topRight - menuOffset;
_alignment = Alignment.bottomLeft;
_offset = Offset(
offset.dx,
editorOffset.dy + editorHeight - offset.dy,
);
/// show above
if (offset.dy > menuHeight) {
_offset = Offset(
_offset.dx,
offset.dy - menuHeight,
);
_alignment = Alignment.topRight;
} else {
_offset = Offset(
_offset.dx,
limitY,
);
}
}
// show on left
if (_offset.dx - editorOffset.dx > editorWidth / 2) {
_alignment = _alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
final x = editorWidth - _offset.dx + editorOffset.dx;
_offset = Offset(
min(x, limitX),
_offset.dy,
);
if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) {
/// show left
if (offset.dx > menuWidth) {
_alignment = _alignment == Alignment.bottomRight
? Alignment.bottomLeft
: Alignment.topLeft;
_offset = Offset(
offset.dx - menuWidth,
_offset.dy,
);
} else {
_offset = Offset(
limitX,
_offset.dy,
);
}
}
}
}

View file

@ -22,6 +22,7 @@ class MobileSelectionMenuWidget extends StatefulWidget {
required this.deleteSlashByDefault,
required this.singleColumn,
required this.startOffset,
required this.showAtTop,
this.nameBuilder,
});
@ -38,6 +39,7 @@ class MobileSelectionMenuWidget extends StatefulWidget {
final bool deleteSlashByDefault;
final bool singleColumn;
final bool showAtTop;
final int startOffset;
final SelectionMenuItemNameBuilder? nameBuilder;
@ -172,27 +174,37 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _showingItems.isEmpty
? _buildNoResultsWidget(context)
: _buildResultsWidget(
context,
_showingItems,
widget.itemCountFilter,
return SizedBox(
height: 192,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showAtTop) Spacer(),
Focus(
focusNode: _focusNode,
child: DecoratedBox(
decoration: BoxDecoration(
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: _showingItems.isEmpty
? _buildNoResultsWidget(context)
: _buildResultsWidget(
context,
_showingItems,
widget.itemCountFilter,
),
),
),
if (!widget.showAtTop) Spacer(),
],
),
);
}

View file

@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
),
if (kDebugMode)
MobileSettingItem(

View file

@ -5,8 +5,6 @@ import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
@ -18,12 +16,10 @@ class AiSettingsGroup extends StatelessWidget {
super.key,
required this.userProfile,
required this.workspaceId,
this.currentWorkspaceMemberRole,
});
final UserProfilePB userProfile;
final String workspaceId;
final AFRolePB? currentWorkspaceMemberRole;
@override
Widget build(BuildContext context) {
@ -32,7 +28,6 @@ class AiSettingsGroup extends StatelessWidget {
create: (context) => SettingsAIBloc(
userProfile,
workspaceId,
currentWorkspaceMemberRole,
)..add(const SettingsAIEvent.started()),
child: BlocBuilder<SettingsAIBloc, SettingsAIState>(
builder: (context, state) {
@ -48,7 +43,7 @@ class AiSettingsGroup extends StatelessWidget {
children: [
Flexible(
child: FlowyText(
state.selectedAIModel,
state.availableModels?.selectedModel.name ?? "",
color: theme.colorScheme.onSurface,
overflow: TextOverflow.ellipsis,
),
@ -84,16 +79,19 @@ class AiSettingsGroup extends StatelessWidget {
title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
builder: (_) {
return Column(
children: availableModels
.mapIndexed(
(index, model) => FlowyOptionTile.checkbox(
text: model,
showTopBorder: index == 0,
isSelected: state.selectedAIModel == model,
children: (availableModels?.models ?? [])
.asMap()
.entries
.map(
(entry) => FlowyOptionTile.checkbox(
text: entry.value.name,
showTopBorder: entry.key == 0,
isSelected:
availableModels?.selectedModel.name == entry.value.name,
onTap: () {
context
.read<SettingsAIBloc>()
.add(SettingsAIEvent.selectModel(model));
.add(SettingsAIEvent.selectModel(entry.value));
context.pop();
},
),

View file

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
@ -7,10 +5,10 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../widgets/widgets.dart';
import 'personal_info.dart';
class PersonalInfoSettingGroup extends StatelessWidget {
@ -32,7 +30,7 @@ class PersonalInfoSettingGroup extends StatelessWidget {
selector: (state) => state.userProfile.name,
builder: (context, userName) {
return MobileSettingGroup(
groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(),
groupTitle: LocaleKeys.settings_accountPage_title.tr(),
settingItemList: [
MobileSettingItem(
name: userName,
@ -60,7 +58,7 @@ class PersonalInfoSettingGroup extends StatelessWidget {
userName: userName,
onSubmitted: (value) => context
.read<SettingsUserViewBloc>()
.add(SettingsUserEvent.updateUserName(value)),
.add(SettingsUserEvent.updateUserName(name: value)),
);
},
);

View file

@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
);
}

View file

@ -40,7 +40,7 @@ class UserSessionSettingGroup extends StatelessWidget {
// delete account button
// only show the delete account button in cloud mode
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[
if (userProfile.authType == AuthTypePB.Server) ...[
const VSpace(16.0),
MobileLogoutButton(
text: LocaleKeys.button_deleteAccount.tr(),
@ -63,8 +63,15 @@ class UserSessionSettingGroup extends StatelessWidget {
);
},
builder: (context, state) {
return const ThirdPartySignInButtons(
expanded: true,
return Column(
children: [
const ContinueWithEmailAndPassword(),
const VSpace(12.0),
const ThirdPartySignInButtons(
expanded: true,
),
const VSpace(16.0),
],
);
},
),

View file

@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
},
(f) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
@ -282,11 +276,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
return showToastNotification(
context,
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()

View file

@ -0,0 +1,53 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/rust_stream.dart';
import 'package:appflowy_result/appflowy_result.dart';
typedef OnUpdateSelectedModel = void Function(AIModelPB model);
class AIModelSwitchListener {
AIModelSwitchListener({required this.objectId}) {
_parser = ChatNotificationParser(id: objectId, callback: _callback);
_subscription = RustStreamReceiver.listen(
(observable) => _parser?.parse(observable),
);
}
final String objectId;
StreamSubscription<SubscribeObject>? _subscription;
ChatNotificationParser? _parser;
void start({
OnUpdateSelectedModel? onUpdateSelectedModel,
}) {
this.onUpdateSelectedModel = onUpdateSelectedModel;
}
OnUpdateSelectedModel? onUpdateSelectedModel;
void _callback(
ChatNotification ty,
FlowyResult<Uint8List, FlowyError> result,
) {
result.map((r) {
switch (ty) {
case ChatNotification.DidUpdateSelectedModel:
onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r));
break;
default:
break;
}
});
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
}

View file

@ -239,9 +239,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
regenerateAnswer: (id, format) {
regenerateAnswer: (id, format, model) {
_clearRelatedQuestions();
_regenerateAnswer(id, format);
_regenerateAnswer(id, format, model);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
@ -435,7 +435,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
metadata: await metadataPBFromMetadata(metadata),
//metadata: await metadataPBFromMetadata(metadata),
);
if (format != null) {
payload.format = format.toPB();
@ -483,6 +483,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
AIModelPB? model,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
@ -505,6 +506,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
if (format != null) {
payload.format = format.toPB();
}
if (model != null) {
payload.model = model;
}
await AIEventRegenerateResponse(payload).send().fold(
(success) {
@ -637,6 +641,7 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.regenerateAnswer(
String id,
PredefinedFormat? format,
AIModelPB? model,
) = _RegenerateAnswer;
// streaming answer

View file

@ -2,19 +2,9 @@ import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
/// Constants for event prefixes.
class AnswerEventPrefix {
static const data = 'data:';
static const error = 'error:';
static const metadata = 'metadata:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
}
/// A stream that receives answer events from an isolate or external process.
/// It caches events that might occur before a listener is attached.
class AnswerStream {
@ -68,31 +58,31 @@ class AnswerStream {
/// Handles incoming events from the underlying stream.
void _handleEvent(String event) {
if (event.startsWith(AnswerEventPrefix.data)) {
if (event.startsWith(AIStreamEventPrefix.data)) {
_hasStarted = true;
final newText = event.substring(AnswerEventPrefix.data.length);
final newText = event.substring(AIStreamEventPrefix.data.length);
_text += newText;
_onData?.call(_text);
} else if (event.startsWith(AnswerEventPrefix.error)) {
_error = event.substring(AnswerEventPrefix.error.length);
} else if (event.startsWith(AIStreamEventPrefix.error)) {
_error = event.substring(AIStreamEventPrefix.error.length);
_onError?.call(_error!);
} else if (event.startsWith(AnswerEventPrefix.metadata)) {
final s = event.substring(AnswerEventPrefix.metadata.length);
} else if (event.startsWith(AIStreamEventPrefix.metadata)) {
final s = event.substring(AIStreamEventPrefix.metadata.length);
_onMetadata?.call(parseMetadata(s));
} else if (event == AnswerEventPrefix.aiResponseLimit) {
} else if (event == AIStreamEventPrefix.aiResponseLimit) {
_aiLimitReached = true;
_onAIResponseLimit?.call();
} else if (event == AnswerEventPrefix.aiImageResponseLimit) {
} else if (event == AIStreamEventPrefix.aiImageResponseLimit) {
_aiImageLimitReached = true;
_onAIImageResponseLimit?.call();
} else if (event.startsWith(AnswerEventPrefix.aiMaxRequired)) {
final msg = event.substring(AnswerEventPrefix.aiMaxRequired.length);
} else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length);
if (_onAIMaxRequired != null) {
_onAIMaxRequired!(msg);
} else {
_pendingAIMaxRequiredEvents.add(msg);
}
} else if (event.startsWith(AnswerEventPrefix.localAINotReady)) {
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
if (_onLocalAIInitializing != null) {
_onLocalAIInitializing!();
} else {

View file

@ -1,7 +1,6 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
@ -9,8 +8,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -51,14 +48,14 @@ class AIChatPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) {
return Center(
child: FlowyText(
LocaleKeys.chat_unsupportedCloudPrompt.tr(),
fontSize: 20,
),
);
}
// if (userProfile.authenticator != AuthTypePB.Server) {
// return Center(
// child: FlowyText(
// LocaleKeys.chat_unsupportedCloudPrompt.tr(),
// fontSize: 20,
// ),
// );
// }
return MultiBlocProvider(
providers: [
@ -73,6 +70,7 @@ class AIChatPage extends StatelessWidget {
/// [AIPromptInputBloc] is used to handle the user prompt
BlocProvider(
create: (_) => AIPromptInputBloc(
objectId: view.id,
predefinedFormat: PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.bulletList,
@ -264,10 +262,13 @@ class _ChatContentPage extends StatelessWidget {
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null)),
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)),
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
@ -384,13 +385,26 @@ class _ChatContentPage extends StatelessWidget {
}
}
class _Input extends StatelessWidget {
class _Input extends StatefulWidget {
const _Input({
required this.view,
});
final ViewPB view;
@override
State<_Input> createState() => _InputState();
}
class _InputState extends State<_Input> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
@ -420,6 +434,7 @@ class _Input extends StatelessWidget {
return UniversalPlatform.isDesktop
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},

View file

@ -27,7 +27,7 @@ class ChatAIAvatar extends StatelessWidget {
child: const CircleAvatar(
backgroundColor: Colors.transparent,
child: FlowySvg(
FlowySvgs.flowy_logo_s,
FlowySvgs.app_logo_s,
size: Size.square(16),
blendMode: null,
),

View file

@ -41,7 +41,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChange);
textController.addListener(handleTextControllerChanged);
// focusNode.onKeyEvent = handleKeyEvent;
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -197,7 +197,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
);
}
void handleTextControllerChange() {
void handleTextControllerChanged() {
if (textController.value.isComposingRangeValid) {
return;
}

View file

@ -46,7 +46,7 @@ class ChatWelcomePage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_logo_xl,
FlowySvgs.app_logo_xl,
size: Size.square(32),
blendMode: null,
),

View file

@ -0,0 +1,145 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
Future<AIModelPB?> showChangeModelBottomSheet(
BuildContext context,
List<AIModelPB> models,
) {
return showMobileBottomSheet<AIModelPB?>(
context,
showDragHandle: true,
builder: (context) => _ChangeModelBottomSheetContent(models: models),
);
}
class _ChangeModelBottomSheetContent extends StatefulWidget {
const _ChangeModelBottomSheetContent({
required this.models,
});
final List<AIModelPB> models;
@override
State<_ChangeModelBottomSheetContent> createState() =>
_ChangeModelBottomSheetContentState();
}
class _ChangeModelBottomSheetContentState
extends State<_ChangeModelBottomSheetContent> {
AIModelPB? model;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(
onCancel: () => Navigator.of(context).pop(),
onDone: () => Navigator.of(context).pop(model),
),
const VSpace(4.0),
_Body(
models: widget.models,
selectedModel: model,
onSelectModel: (format) {
setState(() => model = format);
},
),
const VSpace(16.0),
],
);
}
}
class _Header extends StatelessWidget {
const _Header({
required this.onCancel,
required this.onDone,
});
final VoidCallback onCancel;
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44.0,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: AppBarBackButton(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
onTap: onCancel,
),
),
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: FlowyText(
LocaleKeys.chat_switchModel_label.tr(),
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
Align(
alignment: Alignment.centerRight,
child: AppBarDoneButton(
onTap: onDone,
),
),
],
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.models,
required this.selectedModel,
required this.onSelectModel,
});
final List<AIModelPB> models;
final AIModelPB? selectedModel;
final void Function(AIModelPB) onSelectModel;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: models
.mapIndexed(
(index, model) => _buildModelButton(model, index == 0),
)
.toList(),
);
}
Widget _buildModelButton(
AIModelPB model, [
bool isFirst = false,
]) {
return FlowyOptionTile.checkbox(
text: model.name,
isSelected: model == selectedModel,
showTopBorder: isFirst,
onTap: () {
onSelectModel(model);
},
);
}
}

View file

@ -21,6 +21,7 @@ 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-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@ -41,6 +42,7 @@ class AIMessageActionBar extends StatefulWidget {
required this.showDecoration,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.onOverrideVisibility,
});
@ -48,6 +50,7 @@ class AIMessageActionBar extends StatefulWidget {
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
final void Function(bool)? onOverrideVisibility;
@override
@ -126,6 +129,12 @@ class _AIMessageActionBarState extends State<AIMessageActionBar> {
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
ChangeModelButton(
isInHoverBar: widget.showDecoration,
onRegenerate: widget.onChangeModel,
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
SaveToPageButton(
textMessage: widget.message as TextMessage,
isInHoverBar: widget.showDecoration,
@ -175,8 +184,7 @@ class CopyButton extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
},
@ -405,6 +413,85 @@ class _ChangeFormatPopoverContentState
}
}
class ChangeModelButton extends StatefulWidget {
const ChangeModelButton({
super.key,
required this.isInHoverBar,
this.popoverMutex,
this.onRegenerate,
this.onOverrideVisibility,
});
final bool isInHoverBar;
final PopoverMutex? popoverMutex;
final void Function(AIModelPB)? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
State<ChangeModelButton> createState() => _ChangeModelButtonState();
}
class _ChangeModelButtonState extends State<ChangeModelButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
controller: popoverController,
mutex: widget.popoverMutex,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
onClose: () => widget.onOverrideVisibility?.call(false),
child: buildButton(context),
popupBuilder: (_) {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
return SelectModelPopoverContent(
models: models,
selectedModel: null,
onSelectModel: widget.onRegenerate,
);
},
);
}
Widget buildButton(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_switchModel_label.tr(),
child: FlowyIconButton(
width: 32.0,
height: DesktopAIChatSizes.messageActionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIChatSizes.messageHoverActionBarIconRadius
: DesktopAIChatSizes.messageActionBarIconRadius,
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
onPressed: () {
widget.onOverrideVisibility?.call(true);
popoverController.show();
},
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,

View file

@ -12,6 +12,7 @@ import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -23,6 +24,7 @@ import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
import 'ai_change_model_bottom_sheet.dart';
import 'ai_message_action_bar.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'message_util.dart';
@ -41,6 +43,7 @@ class ChatAIMessageBubble extends StatelessWidget {
this.isSelectingMessages = false,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Message message;
@ -50,6 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget {
final bool isSelectingMessages;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@ -73,6 +77,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@ -82,6 +87,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@ -91,6 +97,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@ -103,12 +110,14 @@ class ChatAIBottomInlineActions extends StatelessWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@ -127,6 +136,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
showDecoration: false,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
),
const VSpace(32.0),
@ -142,12 +152,14 @@ class ChatAIMessageHover extends StatefulWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@ -229,6 +241,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onChangeModel: widget.onChangeModel,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
@ -302,12 +315,14 @@ class ChatAIMessagePopup extends StatelessWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@ -328,6 +343,8 @@ class ChatAIMessagePopup extends StatelessWidget {
_divider(),
_changeFormatButton(context),
_divider(),
_changeModelButton(context),
_divider(),
_saveToPageButton(context),
],
);
@ -359,8 +376,7 @@ class ChatAIMessagePopup extends StatelessWidget {
}
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
},
@ -399,6 +415,25 @@ class ChatAIMessagePopup extends StatelessWidget {
);
}
Widget _changeModelButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
final result = await showChangeModelBottomSheet(context, models);
if (result != null) {
onChangeModel?.call(result);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
icon: FlowySvgs.ai_sparks_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_switchModel_label.tr(),
);
}
Widget _saveToPageButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {

View file

@ -4,6 +4,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.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/chat_message_stream.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -36,6 +37,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.onSelectedMetadata,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
@ -53,6 +55,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final void Function()? onRegenerate;
final void Function() onStopStream;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
@ -110,6 +113,7 @@ class ChatAIMessageWidget extends StatelessWidget {
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View file

@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart';
void openPageFromMessage(BuildContext context, ViewPB? view) {
if (view == null) {
showToastNotification(
context,
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
type: ToastificationType.error,
);
@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
return;
}
showToastNotification(
context,
richMessage: TextSpan(
children: [
TextSpan(

View file

@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
if (state.content != text) {
emit(state.copyWith(content: text));
await cellController.saveCellData(text);
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
// So for every cell data that will be formatted in the backend.
// It needs to get the formatted data after saving.
add(
NumberCellEvent.didReceiveCellUpdate(
cellController.getCellData(),
),
);
}
},
);

View file

@ -143,12 +143,11 @@ class RelationCellBloc extends Bloc<RelationCellEvent, RelationCellState> {
(f) => null,
);
if (databaseMeta != null) {
final result =
await ViewBackendService.getView(databaseMeta.inlineViewId);
final result = await ViewBackendService.getView(databaseMeta.viewId);
return result.fold(
(s) => DatabaseMeta(
databaseId: databaseId,
inlineViewId: databaseMeta.inlineViewId,
viewId: databaseMeta.viewId,
databaseName: s.name,
),
(f) => null,

View file

@ -241,6 +241,11 @@ class SelectOptionCellEditorBloc
} else if (!state.selectedOptions
.any((option) => option.id == focusedOptionId)) {
_selectOptionService.select(optionIds: [focusedOptionId]);
emit(
state.copyWith(
clearFilter: true,
),
);
}
}

View file

@ -411,23 +411,28 @@ class FieldController {
/// Listen for field setting changes in the backend.
void _listenOnFieldSettingsChanged() {
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
final List<FieldInfo> newFields = fieldInfos;
var updatedField = newFields.firstOrNull;
final newFields = [...fieldInfos];
if (updatedField == null) {
if (newFields.isEmpty) {
return null;
}
final index = newFields
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
if (index != -1) {
newFields[index] =
newFields[index].copyWith(fieldSettings: updatedFieldSettings);
updatedField = newFields[index];
_fieldNotifier.fieldInfos = newFields;
_fieldSettings
..removeWhere(
(field) => field.fieldId == updatedFieldSettings.fieldId,
)
..add(updatedFieldSettings);
return newFields[index];
}
_fieldNotifier.fieldInfos = newFields;
return updatedField;
return null;
}
_fieldSettingsListener.start(

View file

@ -17,11 +17,11 @@ class RelationDatabaseListCubit extends Cubit<RelationDatabaseListState> {
.send()
.fold<List<DatabaseMetaPB>>((s) => s.items, (f) => []);
final futures = metaPBs.map((meta) {
return ViewBackendService.getView(meta.inlineViewId).then(
return ViewBackendService.getView(meta.viewId).then(
(result) => result.fold(
(s) => DatabaseMeta(
databaseId: meta.databaseId,
inlineViewId: meta.inlineViewId,
viewId: meta.viewId,
databaseName: s.name,
),
(f) => null,
@ -43,10 +43,10 @@ class DatabaseMeta with _$DatabaseMeta {
/// id of the database
required String databaseId,
/// id of the inline view
required String inlineViewId,
/// id of the view
required String viewId,
/// name of the database, currently identical to the name of the inline view
/// name of the database
required String databaseName,
}) = _DatabaseMeta;
}

View file

@ -73,27 +73,24 @@ class RelatedRowDetailPageBloc
});
}
/// initialize bloc through the `database_id` and `row_id`. The process is as
/// follows:
/// 1. use the `database_id` to get the database meta, which contains the
/// `inline_view_id`
/// 2. use the `inline_view_id` to instantiate a `DatabaseController`.
/// 3. use the `row_id` with the DatabaseController` to create `RowController`
void _init(String databaseId, String initialRowId) async {
final databaseMeta =
await DatabaseEventGetDatabaseMeta(DatabaseIdPB(value: databaseId))
.send()
.fold<DatabaseMetaPB?>((s) => s, (f) => null);
if (databaseMeta == null) {
final viewId = await DatabaseEventGetDefaultDatabaseViewId(
DatabaseIdPB(value: databaseId),
).send().fold(
(pb) => pb.value,
(error) => null,
);
if (viewId == null) {
return;
}
final inlineView =
await ViewBackendService.getView(databaseMeta.inlineViewId)
.fold((viewPB) => viewPB, (f) => null);
if (inlineView == null) {
final databaseView = await ViewBackendService.getView(viewId)
.fold((viewPB) => viewPB, (f) => null);
if (databaseView == null) {
return;
}
final databaseController = DatabaseController(view: inlineView);
final databaseController = DatabaseController(view: databaseView);
await databaseController.open().fold(
(s) => databaseController.setIsLoading(false),
(f) => null,
@ -104,7 +101,7 @@ class RelatedRowDetailPageBloc
}
final rowController = RowController(
rowMeta: rowInfo.rowMeta,
viewId: inlineView.id,
viewId: databaseView.id,
rowCache: databaseController.rowCache,
);

View file

@ -30,9 +30,9 @@ class DatabaseSyncBloc extends Bloc<DatabaseSyncEvent, DatabaseSyncBlocState> {
.then((value) => value.fold((s) => s, (f) => null));
emit(
state.copyWith(
shouldShowIndicator: userProfile?.authenticator ==
AuthenticatorPB.AppFlowyCloud &&
databaseId != null,
shouldShowIndicator:
userProfile?.authType == AuthTypePB.Server &&
databaseId != null,
),
);
if (databaseId != null) {

View file

@ -386,15 +386,15 @@ class _BoardContentState extends State<_BoardContent> {
scrollManager: scrollManager,
),
),
cardBuilder: (context, column, columnItem) =>
cardBuilder: (cardContext, column, columnItem) =>
MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
value: cardContext.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
value: cardContext.read<BoardActionsCubit>(),
),
BlocProvider(
create: (_) => ViewLockStatusBloc(view: widget.view)
@ -402,7 +402,7 @@ class _BoardContentState extends State<_BoardContent> {
),
],
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
builder: (context, state) {
builder: (lockStatusContext, state) {
return IgnorePointer(
ignoring: state.isLocked,
child: _BoardCard(
@ -412,6 +412,13 @@ class _BoardContentState extends State<_BoardContent> {
notifier: widget.focusScope,
cellBuilder: cellBuilder,
compactMode: compactMode,
onOpenCard: (rowMeta) => _openCard(
context: context,
databaseController: lockStatusContext
.read<BoardBloc>()
.databaseController,
rowMeta: rowMeta,
),
),
);
},
@ -581,6 +588,7 @@ class _BoardCard extends StatefulWidget {
required this.cellBuilder,
required this.notifier,
required this.compactMode,
required this.onOpenCard,
});
final AppFlowyGroupData afGroupData;
@ -589,6 +597,7 @@ class _BoardCard extends StatefulWidget {
final CardCellBuilder cellBuilder;
final BoardFocusScope notifier;
final bool compactMode;
final void Function(RowMetaPB) onOpenCard;
@override
State<_BoardCard> createState() => _BoardCardState();
@ -698,10 +707,8 @@ class _BoardCardState extends State<_BoardCard> {
groupingFieldId: widget.groupItem.fieldInfo.id,
isEditing: _isEditing,
cellBuilder: widget.cellBuilder,
onTap: (context) => _openCard(
context: context,
databaseController: databaseController,
rowMeta: context.read<CardBloc>().rowController.rowMeta,
onTap: (context) => widget.onOpenCard(
context.read<CardBloc>().rowController.rowMeta,
),
onShiftTap: (_) {
Focus.of(context).requestFocus();

View file

@ -108,7 +108,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
top: 0,
bottom: 0,
right: 0,
child: _DragToExpandLine(),
child: DragToExpandLine(),
);
return _GridHeaderCellContainer(
@ -158,8 +158,11 @@ class _GridHeaderCellContainer extends StatelessWidget {
}
}
class _DragToExpandLine extends StatelessWidget {
const _DragToExpandLine();
@visibleForTesting
class DragToExpandLine extends StatelessWidget {
const DragToExpandLine({
super.key,
});
@override
Widget build(BuildContext context) {

View file

@ -362,9 +362,13 @@ const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder';
const kDatabasePluginWidgetBuilderNode = 'node';
class DatabasePluginWidgetBuilderSize {
const DatabasePluginWidgetBuilderSize({required this.horizontalPadding});
const DatabasePluginWidgetBuilderSize({
required this.horizontalPadding,
this.verticalPadding = 16.0,
});
final double horizontalPadding;
final double verticalPadding;
}
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
@ -16,6 +14,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import '../editable_cell_skeleton/checklist.dart';
@ -201,19 +200,16 @@ class _ChecklistItems extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
),
buildDefaultDragHandles: false,

View file

@ -203,7 +203,7 @@ class MobileURLEditor extends StatelessWidget {
ClipboardData(text: textEditingController.text),
);
Fluttertoast.showToast(
msg: LocaleKeys.grid_url_copiedNotification.tr(),
msg: LocaleKeys.message_copy_success.tr(),
gravity: ToastGravity.BOTTOM,
);
context.pop();

View file

@ -14,6 +14,7 @@ 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 '../../application/cell/bloc/checklist_cell_bloc.dart';
import 'checklist_cell_textfield.dart';
@ -125,19 +126,16 @@ class ChecklistItemList extends StatelessWidget {
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
),
buildDefaultDragHandles: false,

View file

@ -256,7 +256,7 @@ class _CellEditorTitle extends StatelessWidget {
}
void _openRelatedDatbase(BuildContext context) {
FolderEventGetView(ViewIdPB(value: databaseMeta.inlineViewId))
FolderEventGetView(ViewIdPB(value: databaseMeta.viewId))
.send()
.then((result) {
result.fold(

View file

@ -21,8 +21,8 @@ import 'package:appflowy/shared/af_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide UploadImageMenu;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
@ -69,8 +69,7 @@ class RowBanner extends StatefulWidget {
class _RowBannerState extends State<RowBanner> {
final _isHovering = ValueNotifier(false);
late final isLocalMode =
(widget.userProfile?.authenticator ?? AuthenticatorPB.Local) ==
AuthenticatorPB.Local;
(widget.userProfile?.authType ?? AuthTypePB.Local) == AuthTypePB.Local;
@override
void dispose() {

View file

@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
@ -120,29 +121,34 @@ class _RowEditor extends StatelessWidget {
return context;
},
dispose: (_, editorContext) => editorContext.dispose(),
child: EditorDropHandler(
child: AiWriterScrollWrapper(
viewId: view.id,
editorState: editorState,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
dropManagerState: context.read<EditorDropManagerState>(),
child: EditorTransactionService(
child: EditorDropHandler(
viewId: view.id,
editorState: editorState,
child: Provider(
create: (context) =>
DatabasePluginWidgetBuilderSize(horizontalPadding: 0),
child: AppFlowyEditorPage(
shrinkWrap: true,
autoFocus: false,
editorState: editorState,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.only(left: 16, right: 54),
isLocalMode: context.read<DocumentBloc>().isLocalMode,
dropManagerState: context.read<EditorDropManagerState>(),
child: EditorTransactionService(
viewId: view.id,
editorState: editorState,
child: Provider(
create: (context) => DatabasePluginWidgetBuilderSize(
horizontalPadding: 0,
),
child: AppFlowyEditorPage(
shrinkWrap: true,
autoFocus: false,
editorState: editorState,
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: const EdgeInsets.only(left: 16, right: 54),
),
showParagraphPlaceholder: (editorState, _) =>
editorState.document.isEmpty,
placeholderText: (_) =>
LocaleKeys.cardDetails_notesPlaceholder.tr(),
),
showParagraphPlaceholder: (editorState, _) =>
editorState.document.isEmpty,
placeholderText: (_) =>
LocaleKeys.cardDetails_notesPlaceholder.tr(),
),
),
),

View file

@ -1,3 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart';
import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart';
@ -7,8 +8,10 @@ import 'package:appflowy/plugins/database/widgets/row/row_property.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
@ -18,8 +21,10 @@ import 'package:appflowy/workspace/application/action_navigation/navigation_acti
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
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:provider/provider.dart';
import '../../workspace/application/view/view_bloc.dart';
@ -48,18 +53,6 @@ class DatabaseDocumentPage extends StatefulWidget {
class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
EditorState? editorState;
@override
void initState() {
super.initState();
EditorNotification.addListener(_onEditorNotification);
}
@override
void dispose() {
EditorNotification.removeListener(_onEditorNotification);
super.dispose();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
@ -104,7 +97,11 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listener: _onNotificationAction,
listenWhen: (_, curr) => curr.action != null,
child: _buildEditorPage(context, state),
child: AiWriterScrollWrapper(
viewId: widget.view.id,
editorState: editorState,
child: _buildEditorPage(context, state),
),
);
},
),
@ -121,21 +118,34 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
styleCustomizer: EditorStyleCustomizer(
context: context,
padding: EditorStyleCustomizer.documentPadding,
editorState: state.editorState!,
),
header: _buildDatabaseDataContent(context, state.editorState!),
initialSelection: widget.initialSelection,
useViewInfoBloc: false,
placeholderText: (node) =>
node.type == ParagraphBlockKeys.type && !node.isInTable
? LocaleKeys.editor_slashPlaceHolder.tr()
: '',
),
);
return EditorTransactionService(
viewId: widget.view.id,
editorState: state.editorState!,
child: Column(
children: [
if (state.isDeleted) _buildBanner(context),
Expanded(child: appflowyEditorPage),
],
return Provider(
create: (_) {
final context = SharedEditorContext();
context.isInDatabaseRowPage = true;
return context;
},
dispose: (_, editorContext) => editorContext.dispose(),
child: EditorTransactionService(
viewId: widget.view.id,
editorState: state.editorState!,
child: Column(
children: [
if (state.isDeleted) _buildBanner(context),
Expanded(child: appflowyEditorPage),
],
),
),
);
}
@ -208,20 +218,6 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
);
}
void _onEditorNotification(EditorNotificationType type) {
final editorState = this.editorState;
if (editorState == null) {
return;
}
if (type == EditorNotificationType.undo) {
undoCommand.execute(editorState);
} else if (type == EditorNotificationType.redo) {
redoCommand.execute(editorState);
} else if (type == EditorNotificationType.exitEditing) {
editorState.selection = null;
}
}
void _onNotificationAction(
BuildContext context,
ActionNavigationState state,

View file

@ -101,8 +101,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
bool get isLocalMode {
final userProfilePB = state.userProfilePB;
final type = userProfilePB?.authenticator ?? AuthenticatorPB.Local;
return type == AuthenticatorPB.Local;
final type = userProfilePB?.authType ?? AuthTypePB.Local;
return type == AuthTypePB.Local;
}
@override
@ -272,7 +272,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
if (options.inMemoryUpdate) {
Log.trace('skip transaction for in-memory update');
if (enableDocumentInternalLog) {
Log.trace('skip transaction for in-memory update');
}
return;
}
@ -440,7 +442,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final context = AppGlobals.rootNavKey.currentContext;
if (context != null && context.mounted) {
showToastNotification(
context,
message: 'document integrity check failed',
type: ToastificationType.error,
);

View file

@ -31,8 +31,7 @@ class DocumentCollaboratorsBloc
final userProfile = result.fold((s) => s, (f) => null);
emit(
state.copyWith(
shouldShowIndicator:
userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud,
shouldShowIndicator: userProfile?.authType == AuthTypePB.Server,
),
);
final deviceId = ApplicationInfo.deviceId;

View file

@ -30,8 +30,7 @@ class DocumentSyncBloc extends Bloc<DocumentSyncEvent, DocumentSyncBlocState> {
);
emit(
state.copyWith(
shouldShowIndicator:
userProfile?.authenticator == AuthenticatorPB.AppFlowyCloud,
shouldShowIndicator: userProfile?.authType == AuthTypePB.Server,
),
);
_syncStateListener.start(

View file

@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
@ -55,8 +56,6 @@ class _DocumentPageState extends State<DocumentPage>
Selection? initialSelection;
late final documentBloc = DocumentBloc(documentId: widget.view.id)
..add(const DocumentEvent.initial());
late final viewBloc = ViewBloc(view: widget.view)
..add(const ViewEvent.initial());
@override
void initState() {
@ -68,7 +67,6 @@ class _DocumentPageState extends State<DocumentPage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
documentBloc.close();
viewBloc.close();
super.dispose();
}
@ -93,7 +91,11 @@ class _DocumentPageState extends State<DocumentPage>
value: ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
BlocProvider.value(value: viewBloc),
BlocProvider(
create: (context) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
lazy: false,
),
],
child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (prev, curr) => curr.isLocked != prev.isLocked,
@ -126,14 +128,20 @@ class _DocumentPageState extends State<DocumentPage>
return const SizedBox.shrink();
}
return BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
listener: (context, state) {
editorState.editable = !state.isLocked;
},
child:
BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
return MultiBlocListener(
listeners: [
BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
listener: (context, state) =>
editorState.editable = !state.isLocked,
),
BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
),
],
child: AiWriterScrollWrapper(
viewId: widget.view.id,
editorState: editorState,
child: buildEditorPage(context, state),
),
);

View file

@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import 'editor_plugins/link_preview/custom_link_preview_block_component.dart';
import 'editor_plugins/page_block/custom_page_block_component.dart';
/// A global configuration for the editor.
@ -969,11 +970,11 @@ OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder(
);
}
LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return LinkPreviewBlockComponentBuilder(
return CustomLinkPreviewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) {
if (UniversalPlatform.isMobile) {
@ -982,21 +983,6 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
return const EdgeInsets.symmetric(vertical: 10);
},
),
cache: LinkPreviewDataCache(),
showMenu: true,
menuBuilder: (context, node, state) => Positioned(
top: 10,
right: 0,
child: LinkPreviewMenu(node: node, state: state),
),
builder: (_, node, url, title, description, imageUrl) =>
CustomLinkPreviewWidget(
node: node,
url: url,
title: title,
description: description,
imageUrl: imageUrl,
),
);
}

Some files were not shown because too many files have changed in this diff Show more