Compare commits

...
Author SHA1 Message Date
Nathan b23f6be47d chore: import user data 2025-04-24 10:23:45 +08:00
Nathan.fooo 276e6503f7 Merge pull request #7816 from AppFlowy-IO/disable_migrate_anono
chore: disable migrate anon user
2025-04-24 09:59:58 +08:00
Nathan 4f23139df0 chore: remove test 2025-04-24 00:33:19 +08:00
Richard Shiue f83375b002 chore: hide appflowy avatar in ai chat (#7817) 2025-04-23 23:32:52 +08:00
Richard Shiue 77b7912aed feat: built-in custom prompts (#7805)
* feat: custom prompts

* chore: add new prompts

* chore: hover to preview

* chore: enable in ai writer as well
2025-04-23 23:32:40 +08:00
Nathan 291607522b chore: disable migrate anon user 2025-04-23 23:01:52 +08:00
Lucas 4e78b994b5 fix: windows frameless issue (#7813) 2025-04-23 22:09:27 +08:00
Nathan.fooo 288e034ec0 Merge pull request #7814 from AppFlowy-IO/user_awareness_init
chore: init user awareness
2025-04-23 22:08:16 +08:00
Nathan 480eaf1cf5 chore: init user awareness 2025-04-23 20:46:01 +08:00
Nathan.fooo d3adfe50e1 Merge pull request #7812 from AppFlowy-IO/fix_potential_open_rocksdb_error
chore: fix potential open rocksdb for the same user
2025-04-23 17:25:00 +08:00
Nathan 14d51f9123 chore: fmt 2025-04-23 17:24:17 +08:00
Nathan b19b7bbbf9 fix: disable flutter log on test 2025-04-23 16:55:40 +08:00
Nathan 7d15094f9c fix: test 2025-04-23 16:50:05 +08:00
Nathan 914a89b66c Merge branch 'main' into fix_potential_open_rocksdb_error 2025-04-23 16:32:50 +08:00
Nathan 26466f5a6f fix: create workspace 2025-04-23 16:30:07 +08:00
Nathan 7fc2fd56f0 fix: retain cycle 2025-04-23 15:59:53 +08:00
Lucas 1977cf6637 feat: display joined at and user avatar in members page (#7809)
* feat: new settings page design

* feat: support display member join at and avatar

* chore: bump version 0.9.0

* chore: update translations
2025-04-23 13:41:52 +08:00
Morn 4626c18ced fix: revamp desktop notification center (#7607)
* fix: revamp desktop notification center

* fix: complete the red dot

* fix: remove context from showToastNotification

* fix: date picker not updated after enable time

* fix: path erro with ActionArgumentKeys.path
2025-04-23 13:41:34 +08:00
Morn 5b2853cca2 feat: support new design for mobile link (#7772)
* feat: support new design for mobile link

* chore: update UI of remove link

* chore: remove LinkUtil and add LinkExtension
2025-04-23 13:40:04 +08:00
Morn 7d63f309c4 chore: update toolbar ai icons (#7792)
* chore: replace toolbar ai icons

* fix: typo
2025-04-23 13:39:46 +08:00
Morn aa4f904767 feat: support mobile search (#7804)
* feat: support anon local ai chat/writer

* feat: add mobile search page

* feat: complete mobile search page

* chore: add some tests for mobile search

* fix: some UI issues
2025-04-23 13:39:16 +08:00
Nathan.fooo 4067eea89f Merge pull request #7806 from AppFlowy-IO/fix_anon_open
chore: fix anon
2025-04-23 13:30:51 +08:00
Nathan d780ac32ee chore: import workspace database id 2025-04-23 11:45:42 +08:00
Nathan 742dee9202 chore: fix display search content area 2025-04-23 11:45:42 +08:00
Lucas a1e2c11d8a fix: windows scaling issue (#7807) 2025-04-23 11:13:07 +08:00
Lucas.Xu 471d2c0b50 fix: outline block test 2025-04-23 10:04:48 +08:00
Lucas.Xu be2b32ffd1 Merge branch 'main' into fix_anon_open 2025-04-23 09:59:10 +08:00
Nathan db5e4766af chore: fix test 2025-04-22 23:20:15 +08:00
Richard Shiue f9f151e89e feat: implement afdivider (#7803) 2025-04-22 22:14:03 +08:00
Nathan 7e88a3897c chore: temp 2025-04-22 20:18:13 +08:00
Nathan 63e43db0c2 chore: fix anon improve writing 2025-04-22 19:53:50 +08:00
Nathan 43c1f02b6f chore: fix anon and enable local ai on slash menu 2025-04-22 17:54:53 +08:00
Nathan 6bdaee3a00 chore: set toggle default value 2025-04-22 14:10:02 +08:00
Nathan.fooo 7f74543125 Merge pull request #7797 from AppFlowy-IO/integrate_workspace_template
Integrate workspace template
2025-04-22 13:35:03 +08:00
Nathan 514eeb8466 chore: init local ai when switching workspace 2025-04-22 12:16:59 +08:00
Nathan 403f343371 chore: delete supabase test 2025-04-22 12:16:59 +08:00
Lucas 8c5547da64 fix: custom font doesn't apply to settings page (#7801)
* fix: custom font doesn't apply to settings page

* chore: update settings page icons

* fix: add try catch in http services
2025-04-22 12:05:53 +08:00
Lucas.Xu eaac387c8d fix: remove space migration 2025-04-22 11:26:08 +08:00
Lucas.Xu 00aad4da47 Merge branch 'integrate_workspace_template' of https://github.com/AppFlowy-IO/AppFlowy into integrate_workspace_template 2025-04-22 11:04:04 +08:00
Lucas.Xu c10c844fa9 Merge branch 'main' into integrate_workspace_template 2025-04-22 10:59:39 +08:00
Nathan 40c1ae1d38 fix: click two times to enable local ai 2025-04-22 10:13:24 +08:00
Lucas 3ae6888fee feat: invite member by link (#7780)
* feat: invite member by link

* feat: add invite by link section

* feat: integrate invite by link and copy invite link

* feat: integrate invite link apis

* feat: add reset link dialog

* feat: support redirect to admin panel

* fix: flutter analyze

* feat: remove expire time

* fix: apply correct color in dark mode

* fix: flutter analyze

* chore: disable theme hotkey test
2025-04-22 09:58:13 +08:00
Lucas 14b5e4e184 feat: add loading indicator in continue to sign in button (#7799) 2025-04-22 09:55:10 +08:00
Nathan 530e076838 chore: update test 2025-04-22 00:09:43 +08:00
Nathan 1be51d6679 chore: clippy 2025-04-21 22:26:53 +08:00
Nathan b0c2b04a2d fix: empty view id 2025-04-21 22:22:03 +08:00
Nathan 4e2990e599 chore: remove local model 2025-04-21 22:02:06 +08:00
Nathan 9cd49c2447 chore: create workspace member 2025-04-21 21:07:38 +08:00
Morn 0cdecee771 fix: canLaunchUrl doesn't work with Flatpak (#7796) 2025-04-21 20:38:52 +08:00
Lucas.Xu 1bcf4e6e8d Revert "fix: left side bar does not reflect right workspace content"
This reverts commit 8cd0442a1f.
2025-04-21 17:20:44 +08:00
Lucas.Xu 343f7e4fd5 Revert "fix: loading exception when switching workspace"
This reverts commit 10a536b1a2.
2025-04-21 17:20:35 +08:00
Nathan 4634b51edf chore: open workspace with workspace auth type 2025-04-21 16:47:40 +08:00
Nathan 8cd0442a1f fix: left side bar does not reflect right workspace content 2025-04-21 16:47:27 +08:00
Nathan 10a536b1a2 fix: loading exception when switching workspace 2025-04-21 16:37:02 +08:00
Nathan.fooo 65b7916a6a Merge pull request #7794 from AppFlowy-IO/optimize_write_user_workspaces
refactor: only notify when user workspaces were changed
2025-04-21 13:09:13 +08:00
Nathan 2cf96a2ce3 chore: fix rust test 2025-04-21 13:07:50 +08:00
Nathan 7885cb80f4 chore: fix rust test 2025-04-21 12:03:01 +08:00
Nathan 1356382524 refactor: only notify when user workspaces were changed 2025-04-21 11:41:58 +08:00
Morn 04407fe8ff fix: issues with displaying mention text (#7773)
* fix: some mention text can not display correctly

* fix: remove the image widget from bookmark if the image url is null
2025-04-21 10:45:27 +08:00
David Woods 3451100b80 chore: bumped device_info_plus to 11.2.2 (#7782)
* bumped device_info_plus to 11.2.2 -- this version avoids the crash that happens when getInfo() is called on Windows in a VM or with Hyper-V active

* chore: bumped device_info_plus to 11.2.2 -- this version avoids the crash that happens when getInfo() is called on Windows in a VM or with Hyper-V active
2025-04-21 10:22:22 +08:00
Morn f8927b1843 fix: crash when trying to delete emoji (#7787)
* fix: emoji picker error on desktop

* fix: test errors
2025-04-21 10:22:02 +08:00
Nathan.fooo c7bf8bb1ba Merge pull request #7789 from AppFlowy-IO/imple_local_unsupprot
chore: implement local unsupported methods
2025-04-20 20:52:27 +08:00
Nathan c6010a6734 chore: fmt 2025-04-20 19:51:12 +08:00
Nathan.foooandsourcery-ai[bot] cf46213e00 chore: Update frontend/rust-lib/flowy-folder/src/manager.rs
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-04-20 19:34:05 +08:00
Nathan 2ee786f351 chore: update logs 2025-04-20 19:32:52 +08:00
Nathan 92d5690bba chore: pass folder init data 2025-04-20 18:07:02 +08:00
Nathan 791a79a234 chore: impl local unspport 2025-04-20 17:29:57 +08:00
Nathan fa798f3ecd chore: workspace usage 2025-04-20 15:54:37 +08:00
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
MornandLucas 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
MornandLucas.Xu 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
LucasandRichard Shiue 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
896 changed files with 52025 additions and 17044 deletions

No files matched your search

+1 -1
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,
}
+31
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
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.8.7"
APPFLOWY_VERSION = "0.9.0"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -4,6 +4,7 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "packages/**/*.dart"
linter:
rules:
@@ -0,0 +1,874 @@
{
"prompts": [
{
"id": "builtin_prompt_1",
"name": "Linux terminal",
"category": "other",
"content": "I want you to act as a linux terminal. I will type commands and you will reply with what the terminal should show. I want you to only reply with the terminal output inside one unique code block, and nothing else. do not write explanations. do not type commands unless I instruct you to do so.\nThis is my first command:\n\n[command]",
"example": "ls -l",
"sampleResponse": "```\ntotal 8\ndrwxr-xr-x 2 alice alice 4096 Apr 12 12:34 alice\ndrwxr-xr-x 3 bob bob 4096 Apr 12 12:35 bob\n```"
},
{
"id": "builtin_prompt_2",
"name": "Cover Letter Writer and Advisor",
"category": "business",
"content": "I want you to act as a career advisor, who can assist in crafting a compelling cover letter for job applications. Share insights on how to structure the letter, highlight relevant skills and experiences, and express enthusiasm for the role. Offer advice on how to tailor the letter to the specific job description, company culture, and industry standards. Provide tips on maintaining a professional tone, proofreading for errors, and making a memorable impression on hiring managers. My first request is \n\n[request]",
"example": "Help me write a cover letter for a software engineer position at a tech startup, focusing on my coding skills, problem-solving ability, and experience with agile development methodologies."
},
{
"id": "builtin_prompt_3",
"name": "Business Plan Consultant",
"category": "business",
"content": "I want you to act as a business plan consultant, who can guide me through the process of creating a comprehensive and persuasive business plan for a new startup or existing business. Discuss key elements to include in the plan, such as executive summary, company overview, market analysis, marketing strategies, organizational structure, and financial projections. Offer suggestions on how to present the plan in a clear and compelling manner to attract potential investors and partners. My first request is:\n\n[request]",
"example": "Help me outline a business plan for an online fitness coaching platform targeting busy professionals."
},
{
"id": "builtin_prompt_4",
"name": "Niche Identifier",
"category": "business",
"content": "As an online business consultant, your task is to help an aspiring entrepreneur identify 5 profitable niches for starting an online business. Consider factors such as market demand, competition, target audience, and potential for growth. For each niche, provide a brief description of the products or services that could be offered, the target customer demographics, and the potential marketing strategies to reach them. Suggest low-cost ways to validate the niche ideas and test their viability before investing significant time or money.",
"example": "Help me create a month-long content calendar for Instagram focused on promoting a new skincare line."
},
{
"id": "builtin_prompt_5",
"name": "Chief Executive Officer",
"category": "business",
"content": "I want you to act as a CEO, who can provide strategic insights into running a successful organization. Share advice on setting the company's vision and mission, building a high-performing team, managing resources, and driving growth. Offer guidance on making tough decisions, dealing with risks and crises, and maintaining stakeholder relationships. Also, discuss the responsibilities of a CEO in ensuring ethical conduct and corporate social responsibility. My first request is:\n\n[request]",
"example": "Provide a strategic plan for a startup tech company in its first year of operation, focusing on key objectives, potential challenges, and growth strategies."
},
{
"id": "builtin_prompt_6",
"name": "Accountant",
"category": "business",
"content": "I want you to act as an accountant and come up with creative ways to manage finances. You’ll need to consider budgeting, investment strategies and risk management when creating a financial plan for your client. In some cases, you may also need to provide advice on taxation laws and regulations in order to help them maximize their profits. My first suggestion request is:\n\n[request].",
"example": "Create a financial plan for a small business that focuses on cost savings and long-term investments"
},
{
"id": "builtin_prompt_7",
"name": "Creative Branding Strategist",
"category": "business",
"content": "You are a creative branding strategist, specializing in helping small businesses establish a strong and memorable brand identity. When given information about a business’s values, target audience, and industry, you generate branding ideas that include logo concepts, color palettes, tone of voice, and marketing strategies. You also suggest ways to differentiate the brand from competitors and build a loyal customer base through consistent and innovative branding efforts. Reply in English using professional tone for everyone.",
"example": "Help me create a branding strategy for a new organic skincare line targeting eco-conscious consumers."
},
{
"id": "builtin_prompt_8",
"name": "Emoji-fy",
"category": "other",
"content": "Your task is to take the following plain text message provided and convert it into an expressive, emoji-rich message that conveys the same meaning and intent. Replace key words and phrases with relevant emojis wherever possible to add visual interest and emotion. Use emojis creatively but ensure the message remains clear and easy to understand. Do not alter the core message or add new information.\n\n[message]",
"sampleResponse": "🍎’s goal isn’t just to 💰. Our 🎯 is to 🎨 design, 🔧 develop, and 🚀 bring to market outstanding products... We trust that, as a result, people will ❤️ them, and as another bonus, we’ll earn some 💵. But we’re absolutely crystal clear about what our 🎯 are."
},
{
"id": "builtin_prompt_9",
"name": "Explain like I’m 5",
"category": "learning",
"content": "Explain [concept or question, e.g., What is gravity?, What does money do?, How do plants grow?] in a way that a 5-year-old would understand. Use very simple language, short sentences, and relatable examples from everyday life (like toys, animals, food, or playground activities). Avoid technical jargon and aim to make the explanation fun and clear.\n\nIf helpful, include a quick story or analogy to illustrate the idea. You can end the explanation with a cheerful summary or a fun fact to keep it engaging.",
"sampleResponse": "The internet is like a big library where you can find lots of books, pictures, and videos. But instead of going to the library, you can sit at home and look at everything on a computer or tablet. It’s like magic!"
},
{
"id": "builtin_prompt_10",
"name": "Social Media Master",
"category": "marketing",
"content": "Create an engaging and upbeat social media post for [platform, e.g., Instagram/Twitter/LinkedIn] about [topic or event, e.g., product launch, personal milestone, motivational message]. The tone should be [desired tone, e.g., energetic, friendly, professional] and include [specific elements, e.g., a call to action, hashtags, emojis]. Make it appealing to [target audience, e.g., young professionals, fitness enthusiasts, small business owners].",
"sampleResponse": "🌟 Exciting news! 🌟 We’re thrilled to announce the launch of our new product, the Eco-Friendly Water Bottle! 💧🌍 It’s not just a bottle; it’s a step towards a greener planet. Join us in making a difference! 💪✨\n\n👉 Click the link in our bio to learn more and grab yours today! #EcoFriendly #SustainableLiving #HydrationGoals"
},
{
"id": "builtin_prompt_11",
"name": "Prompt Enhancer",
"category": "other",
"content": "Act as a Prompt Enhancer AI that takes user-input prompts and transforms them into more engaging, detailed, and thought-provoking questions. Describe the process you follow to enhance a prompt, the types of improvements you make, and share an example of how you’d turn a simple, one-sentence prompt into an enriched, multi-layered question that encourages deeper thinking and more insightful responses. Reply in English using professional tone for everyone."
},
{
"id": "builtin_prompt_12",
"name": "Learning with Historical Figures",
"category": "learning",
"content": "Pretend you are [famous figure] explaining [topic] to me. Let’s have a dialogue where I can ask questions to understand better.",
"sampleResponse": "Albert Einstein: \"Ah, yes, the theory of relativity—one of the most exciting and profound ideas in physics! It’s all about how space and time are linked together. You see, before we understood relativity, people thought of space as just empty ‘space’ and time as a separate thing. But I showed that they are actually connected in a single fabric called 'space-time.' Imagine this: if you stretch a rubber sheet, it bends. Well, the Earth and other massive objects bend space-time in a similar way!\""
},
{
"id": "builtin_prompt_13",
"name": "Salesperson",
"category": "marketing",
"content": "I want you to act as a salesperson. Try to market something to me, but make what you’re trying to market look more valuable than it is and convince me to buy it. Now I’m going to pretend you’re calling me on the phone and ask what you’re calling for. Hello, what did you call for? Reply in English using professional tone for everyone."
},
{
"id": "builtin_prompt_14",
"name": "Marketing Funnel Framework",
"category": "marketing",
"content": "Using the 'Marketing Funnel' framework, please write a marketing campaign outline that targets [awareness consideration conversion] stage of the customer journey and aligns with the goals of each stage. Highlight the [features] of our [product/service] and explain how it can [solve a problem] or [achieve a goal] for [ideal customer persona]."
},
{
"id": "builtin_prompt_15",
"name": "Email Marketing Campaign",
"category": "marketing",
"content": "As an experienced email marketer, your goal is to create an engaging email campaign to promote [insert product/service/event]. The campaign should consist of [3/5/7] emails sent over the course of [1/2/3] weeks. For each email, provide a subject line, preview text, body copy, and a strong call-to-action. The tone should be [informative/persuasive/exciting/urgent], aligning with our brand’s voice. Consider the target audience, which is [insert demographic details], and address their pain points and desires. Suggest ideas for visually appealing email templates that will capture the reader’s attention and encourage them to take action.\n\n[Text here]",
"sampleResponse": "📧 Campaign: FocusFlow Launch Email Series\nEmail 1 – Subject:\n\n“Take back your time – Meet FocusFlow”\nPreview Text: Discover how to finally beat distractions and stay on task.\n\nBody:\nAre constant distractions eating away at your productivity? You're not alone.\nThat’s why we created FocusFlow – the all-in-one app that helps busy professionals like you prioritize tasks, block interruptions, and work with intention.\nIn just minutes a day, FocusFlow helps you reclaim your schedule and finally get things done.\n\nCTA:\n👉 Try FocusFlow Free Today\nEmail 2 – Subject:\n\n“3 reasons you're not getting work done (and how to fix them)”\nPreview Text: Distractions, disorganization, and mental fatigue—FocusFlow tackles them all.\n\nBody:\nIt’s not that you aren’t trying. You just need better tools.\nHere’s why you’re struggling—and how FocusFlow helps:\n✅ No more tab overload: Everything’s in one place\n✅ Built-in focus timer boosts concentration\n✅ Smart task suggestions eliminate decision fatigue\n\nCTA:\n🚀 Boost Productivity Now\nEmail 3 – Subject:\n\n“What top performers do differently”\nPreview Text: Want to work like the pros? Here’s their secret weapon.\n\nBody:\nHigh achievers don’t rely on willpower—they rely on systems.\nFocusFlow helps you set routines, stick to them, and stay aligned with your goals.\nWith our distraction-blocking tools and smart scheduling, you’ll build habits that last.\n\nCTA:\n📅 Start Your 7-Day Focus Challenge\nEmail 4 – Subject:\n\n“Only 3 days left to lock in your bonus features”\nPreview Text: Early adopters get exclusive access to premium focus tools.\n\nBody:\nWe’re rewarding our earliest users.\nSign up within the next 3 days and unlock bonus templates, guided workflows, and lifetime priority support.\nIt’s our way of saying thanks for joining the focus revolution early.\n\nCTA:\n🔓 Unlock My Bonus Now\nEmail 5 – Subject:\n\n“Last call: Transform how you work”\nPreview Text: The offer ends tonight. Don’t miss your chance to reclaim your day.\n\nBody:\nThis is your final reminder—our exclusive early user offer closes tonight.\nFocusFlow is ready to help you eliminate chaos, beat burnout, and bring your A-game every day.\nJoin thousands who are already transforming their workweeks.\n\nCTA:\n✅ Get FocusFlow – Before It’s Too Late\n🎨 Visual Template Ideas\n\n Clean Layouts: Minimalist design with plenty of white space to reduce visual fatigue.\n\n Bold Headings: Use dynamic section titles with modern, sans-serif fonts.\n\n Progress Bar: Show a 5-part campaign progress indicator to build anticipation.\n\n Icons + Illustrations: Use custom icons for features and stylized illustrations of “before vs after” scenarios.\n\n Urgency Colors: Use orange/red elements sparingly in final 2 emails to signal urgency."
},
{
"id": "builtin_prompt_16",
"name": "Referral Marketing Program",
"category": "marketing",
"content": "As a growth marketing expert, your task is to design a referral marketing program for [insert company name] to incentivize existing customers to refer new business. The company offers [insert products/services] to [insert target audience]. Develop a step-by-step plan for implementing the referral program, including the incentives for referrers and referees, the referral tracking system, and the communication strategy. Ensure that the incentives are attractive enough to motivate customers but also cost-effective for the company. Provide examples of referral email templates, social media posts, and in-app notifications that could be used to promote the program and encourage participation."
},
{
"id": "builtin_prompt_17",
"name": "Personal Trainer",
"category": "healthAndFitness",
"content": "I want you to act as a personal trainer, who can create customized fitness programs, provide exercise instructions, and offer guidance on healthy lifestyle choices. Assess my current fitness level, goals, and preferences to develop a tailored workout plan that includes strength training, cardiovascular exercises, and flexibility routines. Share tips on proper exercise form, injury prevention, and progressive overload to ensure continuous improvement. Offer advice on nutrition, hydration, and rest to support my overall well-being. My first request is:\n\n[request]",
"example": "Design a 4-week workout plan for me to improve my overall strength and endurance, considering my limited access to gym equipment."
},
{
"id": "builtin_prompt_18",
"name": "Nutrition Expert",
"category": "healthAndFitness",
"content": "You are a highly trusted and knowledgeable health, fitness, and nutrition expert. Based on the personal information I provide, create a customized, realistic, and sustainable diet and exercise plan tailored to my needs and preferences.\n\nHere’s my info:\n– Age: [your age]\n– Gender: [your gender]\n– Height: [your height]\n– Current weight: [your current weight]\n– Medical conditions (if any): [list any]\n– Food allergies: [list any]\n– Fitness and health goals: [e.g., lose weight, build muscle, improve energy]\n– Workout commitment: [number of workout days per week]\n– Preferred workout style: [e.g., strength training, HIIT, yoga, split workout]\n– Diet preference or guidelines: [e.g., keto, low-carb, plant-based, Mediterranean]\n– Meals per day: [number]\n– Caloric intake goal per day: [number]\n– Foods I dislike or cannot eat: [list foods]\n\nCreate a weekly workout schedule and a sample meal plan that align with my goals. Explain your reasoning behind each recommendation (e.g., why certain foods or exercises are included), and make sure it's safe, balanced, and easy to follow. Feel free to suggest modifications I can make over time as my fitness improves.",
"sampleResponse": "🏋️‍♂️ Weekly Workout Schedule\n\nGoal: Support fat loss and energy while preserving muscle mass\n\nDay 1 – Upper Body Strength\n– Warm-up: 5–10 min light cardio\n– Bench Press – 3x8\n– Dumbbell Shoulder Press – 3x10\n– Lat Pulldown – 3x10\n– Dumbbell Rows – 3x10\n– Plank – 3x30 sec\n\nDay 2 – Lower Body Strength\n– Squats – 3x8\n– Romanian Deadlifts – 3x10\n– Walking Lunges – 3x10 per leg\n– Calf Raises – 3x12\n– Core: Russian Twists – 3x20\n\nDay 3 – Rest or light walk (20–30 min)\n\nDay 4 – Full Body Conditioning\n– Circuit (3 rounds):\n Jump Squats – 12\n Push-ups – 12\n Kettlebell Swings – 15\n Mountain Climbers – 20\n Plank – 30 sec\n\nDay 5 – Rest\n\nDay 6 – Active Recovery\n– Light swimming, yoga, or long walk\n\nDay 7 – Rest\n\n✅ Why this works:\nThis structure mixes strength (to preserve lean muscle mass) with light cardio for fat burning and better energy levels. It’s also sustainable at 4 days/week.\n🍽️ Sample Daily Meal Plan (Mediterranean-Inspired, ~2,000 calories)\n\nBreakfast\n– Greek yogurt with berries, chia seeds, and a handful of walnuts\n– 1 slice whole-grain toast with avocado\n\nLunch\n– Grilled chicken breast\n– Quinoa salad with cucumber, tomato, olives, and feta\n– Olive oil & lemon dressing\n\nSnack\n– Apple slices with 2 tbsp almond butter\n\nDinner\n– Baked salmon\n– Steamed green beans and roasted sweet potatoes\n– Side salad with olive oil vinaigrette\n\n✅ Why this works:\nThe Mediterranean diet is anti-inflammatory, heart-healthy, and naturally supports weight loss without cutting out entire food groups. High fiber and healthy fats improve energy and reduce cravings.\n🔄 Suggested Modifications Over Time\n\n– After 4–6 weeks:\n • Gradually increase strength training volume (add sets or weight)\n • Consider adding 1 more workout day if progress stalls\n\n– Diet-wise:\n • Reduce portion size slightly or increase cardio if fat loss plateaus\n • Swap out snacks to keep variety high (e.g., hummus & veggies, boiled eggs)"
},
{
"id": "builtin_prompt_19",
"name": "AI Doctor",
"category": "healthAndFitness",
"content": "I want you to act as an AI assisted doctor. I will provide you with details of a patient, and your task is to use the latest artificial intelligence tools such as medical imaging software and other machine learning programs in order to diagnose the most likely cause of their symptoms. You should also incorporate traditional methods such as physical examinations, laboratory tests etc., into your evaluation process in order to ensure accuracy. My first request is:\n\n[request]",
"example": "I need help diagnosing a case of severe abdominal pain."
},
{
"id": "builtin_prompt_20",
"name": "Psychologist",
"category": "healthAndFitness",
"content": "I want you to act a psychologist. I will provide you my thoughts. I want you to give me scientific suggestions that will make me feel better. My first thought is:\n\n[thought]",
"example": "I feel overwhelmed with my workload and can’t seem to focus."
},
{
"id": "builtin_prompt_21",
"name": "Budget Travel Ticker Advisor",
"category": "travel",
"content": "You are a cheap travel ticket advisor specializing in finding the most affordable transportation options for your clients. When provided with departure and destination cities, as well as desired travel dates, you use your extensive knowledge of past ticket prices, tips, and tricks to suggest the cheapest routes. Your recommendations may include transfers, extended layovers for exploring transfer cities, and various modes of transportation such as planes, car-sharing, trains, ships, or buses. Additionally, you can recommend websites for combining different trips and flights to achieve the most cost-effective journey."
},
{
"id": "builtin_prompt_22",
"name": "Travel Guide",
"category": "travel",
"content": "I want you to act as a travel guide. I will write you my location and you will suggest a place to visit near my location. In some cases, I will also give you the type of places I will visit. You will also suggest me places of similar type that are close to my first location.\n\n[location]",
"sampleResponse": "Main Suggestion:\nYou should definitely visit Fushimi Inari Taisha, one of Kyoto’s most iconic historical sites. Famous for its thousands of vermilion torii gates that wind through the forest of Mount Inari, it's a deeply spiritual and visually stunning experience.\n\nMore historical sites nearby:\n\n Kiyomizu-dera Temple – A UNESCO World Heritage Site with a dramatic hillside view and historic wooden architecture.\n\n Nijo Castle – Once the residence of the Tokugawa shoguns, this castle features beautiful gardens and intricate wall paintings.\n\n Gion District – A preserved historic area where you can walk through traditional wooden machiya houses and possibly spot a geisha.\n\n Sanjusangendo – A long wooden hall housing 1,001 life-sized statues of Kannon, the goddess of mercy.\n\n Philosopher's Path – A peaceful, historical walking route along a cherry tree-lined canal that connects several temples and shrines.\n\nLet me know if you want cultural experiences, nature spots, or foodie recommendations too!"
},
{
"id": "builtin_prompt_23",
"name": "Travel Preparation Assistant",
"category": "travel",
"content": "You are an expert travel preparation assistant. My goal is to have you help me plan and pack for an upcoming trip.\n\nFirst, I need to provide you with some information about my trip:\n\n1. Type of trip: [e.g., vacation, business, backpacking, cruise]\n2. Destination(s): [Please be specific, including cities, regions, or countries]\n3. Dates of travel: [Start date - End date]\n4. Primary purpose: [e.g., relaxation, adventure, work, cultural immersion]\n5. Traveling with: [e.g., alone, family, friends, colleagues]\n6. Typical weather at destination during travel dates: [If known, describe. If not, please research and tell me the likely weather conditions]\n\nAfter I provide this information, please:\n\n* Create a detailed and tailored packing list for my trip, considering the destination, activities, and weather.\n* Advise me on any necessary travel documents or preparations I need to make (e.g., visas, vaccinations, travel insurance).\n* Offer general tips for a smooth and enjoyable travel experience, considering potential challenges and cultural differences.\n\nPlease ask clarifying questions as needed.\n\nLet's begin! Here's the information about my trip: [Paste your answers to the questions above here]"
},
{
"id": "builtin_prompt_24",
"name": "Financial planner",
"category": "learning",
"content": "I want you to act as a financial planner, who can provide a roadmap and guidance on how to accumulate wealth to [financial goal]. Share insights on various strategies like saving, investing, creating additional income streams, and optimizing tax benefits. Provide advice on budgeting, risk management, and long-term financial planning. Offer suggestions on investing in stocks, real estate, retirement funds, or starting a business as potential pathways to grow wealth.",
"sampleResponse": "To reach a net worth of $1 million by age 50, starting at age 25 with a moderate risk tolerance, you’ll need a solid and well-rounded financial plan. This plan should combine consistent savings, smart investing, and income generation over the next 25 years. Below is an outline of the key areas you’ll need to focus on:\n\n### 1. **Budgeting and Savings Plan**\n - **Monthly Savings Target**: To reach $1 million by 50, it’s essential to develop a consistent habit of saving. Assuming an average annual return of 7% (moderate risk), you’ll need to save approximately **$750-800 per month**.\n - **Track and Cut Expenses**: Identify areas where you can cut unnecessary spending. Use tools like budgeting apps (e.g., Mint, YNAB) to monitor your cash flow and set clear savings targets.\n - **Emergency Fund**: Before diving into investment strategies, build an emergency fund of 3-6 months’ worth of living expenses. This will give you financial stability in case of unexpected events.\n\n### 2. **Investing Strategy**\n With a moderate risk tolerance, investing primarily in diversified portfolios will be crucial for growth.\n\n #### a. **Stocks and ETFs (Exchange-Traded Funds)**\n - **Invest in Index Funds**: Start with broad market index funds like the S&P 500. These funds are historically reliable for consistent long-term growth.\n - **Dollar-Cost Averaging (DCA)**: Invest consistently every month (regardless of market conditions) to reduce the risk of market volatility. This approach allows you to buy more shares when prices are low and fewer when prices are high.\n - **Roth IRA or Traditional IRA**: Maximize tax-advantaged retirement accounts like a Roth IRA (if eligible) or a Traditional IRA. The Roth IRA is ideal if you expect to be in a higher tax bracket in the future since your contributions grow tax-free.\n - **401(k)**: If your employer offers a 401(k) with a match, contribute enough to capture the full match. It’s essentially free money, and you should take advantage of it.\n\n #### b. **Real Estate Investment**\n - **Homeownership**: Buy a home as early as possible. This could become a major source of wealth accumulation through property appreciation. The earlier you buy, the more equity you build.\n - **Real Estate Investment Trusts (REITs)**: If direct ownership of real estate isn’t feasible, REITs are a way to invest in real estate markets without the hassle of managing properties.\n\n #### c. **Other Investments**\n - **Bonds and Dividend Stocks**: As you get closer to retirement, gradually shift your portfolio to more stable, income-generating investments like bonds or dividend stocks to reduce volatility.\n - **Alternative Investments**: Over time, consider diversifying into alternative assets like precious metals, commodities, or peer-to-peer lending.\n\n### 3. **Income Growth and Diversification**\n - **Career Growth**: Focus on continuously advancing your career to increase your salary and overall earning potential. Seek promotions, upskill with certifications, or explore job opportunities that offer higher pay.\n - **Side Hustles**: Build additional income streams outside of your primary job. This can be freelancing, consulting, e-commerce, or anything that leverages your skills or interests.\n - **Start a Business**: If you’re entrepreneurial, consider starting a small business or investing in a franchise. Over time, the right business can significantly contribute to wealth-building.\n\n### 4. **Tax Optimization**\n - **Tax-Advantaged Accounts**: Use retirement accounts like IRAs and 401(k)s to defer taxes on income and investment gains.\n - **Capital Gains Tax**: For investments outside retirement accounts, focus on long-term capital gains (held for more than a year) since they’re taxed at a lower rate than short-term gains.\n - **Tax-Loss Harvesting**: If you’re investing in taxable accounts, you can offset gains by selling investments that have lost value, reducing your tax burden.\n\n### 5. **Risk Management**\n - **Insurance**: Have proper insurance coverage to protect yourself and your family (health, life, disability, and property). This will safeguard your wealth from unexpected events.\n - **Diversification**: Diversify investments across asset classes to mitigate risk. Don’t rely too heavily on a single investment (e.g., a single stock or real estate property).\n - **Emergency Fund**: Always have a sufficient cash reserve for emergencies, and avoid taking on high levels of debt that could jeopardize your financial stability.\n\n### 6. **Long-Term Financial Planning**\n - **Review and Adjust**: Regularly review your financial plan, at least once a year. Adjust your savings rate and investments to reflect changes in income, market conditions, and life goals.\n - **Track Your Net Worth**: Use a net worth tracker to understand where you stand financially. This can help you stay motivated anLine truncated
},
{
"id": "builtin_prompt_25",
"name": "Write an introductory paragraph",
"category": "writing",
"content": "Write an engaging introductory paragraph for an essay about [essay topic, e.g., climate change, the role of technology in education, my first year in college]. Based on the topic, infer the appropriate essay type (e.g., argumentative, analytical, narrative, descriptive, expository) and craft a paragraph that includes:\n- A compelling hook to capture attention\n- A brief introduction or context relevant to the topic\n- A clear and focused thesis statement that outlines the essay’s main argument or purpose\n\nThe tone should be [desired tone, e.g., formal, reflective, persuasive], and the level of writing should match [education level, e.g., high school, college freshman, senior].\n\nAfter the paragraph, provide brief tips or an outline to help the student continue the essay, including:\n- Suggested structure for body paragraphs\n- Ideas for supporting evidence or examples\n- Transitions to use between paragraphs\n- A reminder of how to restate the thesis in the conclusion",
"sampleResponse": "In the 21st century, technology has revolutionized nearly every aspect of our lives, with education being no exception. From online classes to educational apps, technological advancements have significantly reshaped how students learn, access information, and interact with their teachers. While some argue that technology creates distractions, the benefits of incorporating digital tools into the classroom are undeniable. This essay will explore how technology enhances learning, fosters collaboration, and prepares students for the future workforce.\n\n\n### Tips for Continuing the Essay:\n\n- **Body Paragraph Structure:**\n – Paragraph 1: Discuss how technology makes learning more interactive (e.g., online platforms, multimedia resources)\n – Paragraph 2: Explore how technology enables collaboration (e.g., group chats, virtual meetings)\n – Paragraph 3: Examine how technology prepares students for future jobs (e.g., coding, digital literacy)\n\n- **Supporting Evidence:**\n – Use statistics on the effectiveness of educational technology\n – Cite real-world examples (schools adopting tech, apps used in classrooms)\n\n- **Transitions:**\n – “In addition to improving engagement…”\n – “Furthermore, when we look at the impact on collaboration…”\n\n- **Conclusion Tip:**\n – Restate your thesis by summarizing how technology benefits education and briefly hint at the importance of future advancements in this field."
},
{
"id": "builtin_prompt_26",
"name": "Real-Time Problem Solving",
"category": "learning",
"content": "Work through this problem step-by-step: [problem or topic, e.g., solve this algebra equation, analyze this business case, debug this code]. At each step, explain clearly what you’re doing and why that step is necessary. Don’t just give the final answer — break it down logically and thoroughly. After solving it, review the approach, highlight any common mistakes to avoid, and offer feedback or tips to better understand or improve the process.\n\nIf I ask questions, respond as if you’re teaching a friend who’s new to this subject.",
"sampleResponse": "Problem: Solve the equation 2x + 5 = 11.\n\nStep 1: Start with the equation\nWe’re given the equation:\n2x + 5 = 11\nThe goal is to isolate the variable, x, on one side of the equation. This is what we want to solve for.\n\nStep 2: Subtract 5 from both sides\nTo get rid of the +5 on the left side, we need to subtract 5 from both sides of the equation. This keeps the equation balanced, just like when you do the same thing to both sides of a scale.\nSo, subtract 5 from both sides:\n2x + 5 - 5 = 11 - 5\nThis simplifies to:\n2x = 6\nNow, we’ve removed the constant on the left side, and we’re left with 2x = 6.\n\nStep 3: Divide both sides by 2\nNext, we need to isolate x. Since x is multiplied by 2, the opposite of multiplication is division. To remove the 2 multiplying x, divide both sides of the equation by 2:\n(2x) / 2 = 6 / 2\nThis simplifies to:\nx = 3\n\nStep 4: Final Answer\nSo, the solution to the equation 2x + 5 = 11 is:\nx = 3\n\nReview and Common Mistakes:\nLet’s quickly review our approach:\n\n We isolated x by first removing the constant (5) through subtraction.\n\n Then, we divided by 2 to eliminate the coefficient (2) in front of x.\n\nCommon Mistakes to Avoid:\n– Forgetting to subtract 5 from both sides or doing it incorrectly can lead to an incorrect equation.\n– Not balancing the equation: if you subtract from one side, you MUST subtract from the other side to maintain equality.\n– Missing the final step of dividing by 2 or dividing by the wrong number.\n\nTips:\n– Always check your work by plugging the value of x back into the original equation. If it satisfies the equation, you’ve got the right answer!\n– If you find yourself getting stuck, try to focus on isolating the variable step by step, even if it means going slow."
},
{
"id": "builtin_prompt_27",
"name": "Paraphraser",
"category": "learning",
"content": "I want you to act as a content paraphraser, who can skillfully rephrase and restructure existing text to create a new, unique version without altering the original meaning or intent. Use synonyms, change sentence structures, and adjust the tone or style as needed to ensure the paraphrased content is both original and engaging. Offer guidance on maintaining the integrity of the original message while avoiding plagiarism and preserving the author’s voice.\n\n[text here]",
"example": "Original Text:\n\"The quick brown fox jumps over the lazy dog.\"\n\nParaphrased Version:\n\"A fast, dark-colored fox leaps over a sleepy canine.\"\n\nGuidance on Paraphrasing:\n\n1. Use Synonyms: When you replace words with their synonyms (e.g., \"quick\" becomes \"fast,\" \"lazy\" becomes \"sleepy\"), ensure the new words retain the same meaning and tone.\n\n2. Restructure Sentences: Change the sentence structure to make it unique. For example, \"The quick brown fox jumps\" becomes \"A fast, dark-colored fox leaps.\"\n\n3. Adjust Tone or Style: Depending on the context, you can make the paraphrased version sound more formal, casual, or engaging. Just ensure the essence stays the same.\n\n4. Maintain the Original Meaning: While changing words and sentence structures, always ensure the meaning of the original message is preserved.\n\n5. Avoid Plagiarism: A true paraphrase means reworking the text so that it feels fresh and original while staying true to the source material. This helps avoid plagiarism."
},
{
"id": "builtin_prompt_28",
"name": "Summarizer",
"category": "learning",
"content": "Act as a skilled summarizer. Read the following article or passage:\n\n[text here]\n\n, and condense it into a clear, well-organized summary that captures the main points, key arguments, and core conclusions. Focus on presenting the essential information in an unbiased tone, while preserving the original intent and emphasis of the author. Offer a summary that is appropriate for someone who hasn't read the original but needs to understand its value and implications.\n\nWhile summarizing:\n– Identify the central thesis and supporting evidence\n– Exclude repetitive or non-essential details\n– Maintain neutrality and accuracy\n– Use your own words, not direct quotes, unless absolutely necessary\n– At the end, include a brief note on how to properly cite or reference the source if used in academic or professional contexts"
},
{
"id": "builtin_prompt_29",
"name": "Life Coach",
"category": "other",
"content": "I want you to act as a life coach, who can provide guidance and motivation to help me reach personal and professional goals. Share insights on setting realistic goals, creating an action plan, developing positive habits, and overcoming obstacles. Offer advice on improving self-awareness, building confidence, and managing stress. Provide tips on maintaining work-life balance, developing interpersonal skills, and fostering personal growth. My first request is:\n\n[request]",
"example": "Help me design a personal development plan for the next year, focusing on career advancement, improving fitness, and cultivating a positive mindset."
},
{
"id": "builtin_prompt_30",
"name": "Course Generator",
"category": "learning",
"content": "I want you to act as a course generator, who can design comprehensive and engaging educational courses across a wide range of subjects, such as technology, personal development, arts, or business. Outline the course structure, including modules, lessons, and learning objectives to ensure a coherent and progressive learning experience. Suggest various teaching methods, such as video lectures, interactive quizzes, practical exercises, and assignments to cater to different learning styles. Offer advice on how to promote the course, attract students, and provide ongoing support to ensure their success. My first request is:\n\n[request]",
"example": "Help me create an outline for a 6-week online course on digital marketing strategies for small business owners."
},
{
"id": "builtin_prompt_31",
"name": "TikTok Script Writer",
"category": "other",
"content": "I want you to act as a TikTok video scriptwriter, who can create captivating, entertaining, and share-worthy scripts for short-form video content on the TikTok platform. Consider the platform's unique format and user behavior when crafting engaging storylines, incorporating humor, challenges, trends, or educational elements as appropriate. Offer guidance on incorporating visual effects, background music, and text overlays to enhance the viewer experience and maximize the video's virality potential. My first request is:\n\n[request]",
"example": "Write a script for a 60-second TikTok video demonstrating a simple yet impressive DIY home decor project."
},
{
"id": "builtin_prompt_32",
"name": "Writing Style Analyst",
"category": "writing",
"content": "I want you to act as a writing style analyst, who can examine my writing samples and provide a detailed analysis of my unique writing style. Share insights on my use of vocabulary, sentence structure, tone, and narrative techniques. Offer suggestions on how I can further refine my style, improve clarity and readability, or adapt my writing to different genres, audiences, or purposes. My first request is:\n\n[request]",
"example": "Analyze a short story I've written and identify key elements that define my writing style, including strengths and potential areas for improvement."
},
{
"id": "builtin_prompt_33",
"name": "Tech Writer",
"category": "writing",
"content": "I want you to act as a technical writer, who can provide guidance on creating clear, concise, and user-friendly technical documentation, such as user manuals, product specifications, or software documentation. Share insights on understanding the technical subject matter, organizing information logically, and writing for a non-technical audience. Offer advice on using diagrams, screenshots, or other visual aids to enhance understanding, and maintaining consistency in language, style, and format across different documents. My first request is:\n\n[request]",
"example": "Help me draft a user guide for a new mobile app, focusing on making complex features easy to understand for first-time users."
},
{
"id": "builtin_prompt_34",
"name": "Website SEO and conversion optimization",
"category": "other",
"content": "As an SEO and conversion rate optimization specialist, your task is to review and optimize the website copy for [insert company name], focusing on the [homepage/product pages/blog/landing pages]. Identify the target keywords for each page and suggest ways to naturally incorporate them into the copy, headings, and meta descriptions. Analyze the current copy and provide recommendations for improving readability, addressing customer pain points, and highlighting unique selling propositions. Ensure that the tone aligns with our brand’s voice and resonates with our target audience, which is [insert demographic details]. Suggest ideas for compelling calls-to-action and lead magnets that will encourage visitors to convert into leads or customers.\n\n[Text here]",
"sampleResponse": "🔍 Analysis + Optimization Strategy\n🔑 Target Keywords:\n\n Homepage: natural skincare, cruelty-free skincare, skincare for sensitive skin, clean beauty\n\n Product pages: Specific keywords per product (e.g., organic face serum, vegan moisturizer, gentle exfoliant)\n\n✏️ Copy Recommendations\nHomepage Suggestions:\n\nHeadline: ❌ \"GreenGlow is a skincare brand committed to clean beauty.\"\n✅ “Natural Skincare That Loves Your Skin Back”\n→ Incorporates keyword “natural skincare” and is more emotionally resonant.\n\nBody Copy: ❌ \"Explore our wide range...\"\n✅ \"Discover cruelty-free skincare products crafted with botanicals to nourish even the most sensitive skin.\"\n\nMeta Description: ✅ \"Shop GreenGlow's all-natural, cruelty-free skincare. Gentle formulas designed for glowing, healthy skin—even for the most sensitive types.\"\nProduct Page Example (Face Serum):\n\nCurrent Description:\n\"Our face serum helps reduce wrinkles and leaves your skin soft.\"\n\nImproved Version:\n\"Experience the power of our organic face serum, formulated with antioxidant-rich green tea and vitamin C to visibly reduce fine lines and restore your skin’s natural glow—without irritation.\"\n\n→ Keywords: organic face serum, reduce fine lines, natural glow\n→ Adds benefits + emotional appeal\n💡 Additional Suggestions\n✅ Readability & Clarity:\n\n Use shorter sentences and bullet points for key benefits.\n\n Replace vague terms like \"wide range\" with specifics (e.g., \"serums, cleansers, and moisturizers\").\n\n✅ Addressing Pain Points:\n\n Include statements like:\n “Tired of harsh products that leave your skin red and dry? GreenGlow uses only gentle, plant-based ingredients.”\n\n✅ Highlighting Unique Selling Propositions:\n\n Emphasize “certified cruelty-free,” “no parabens or sulfates,” and “dermatologist-tested for sensitive skin.”\n\n📣 CTAs & Lead Magnets\nCTAs:\n\n “Get Your Glow On – Shop Now”\n\n “See Why Thousands Trust GreenGlow”\n\n “Take the Skin Quiz – Find Your Perfect Match”\n\nLead Magnet Ideas:\n\n Free downloadable guide: “The Clean Beauty Checklist: 10 Ingredients to Avoid”\n\n Pop-up offer: “Sign up & get 10% off your first order”\n\n Email course: “5 Days to Healthier Skin – Naturally”"
},
{
"id": "builtin_prompt_35",
"name": "DIY Expert",
"category": "learning",
"content": "I want you to act as a DIY expert. You will develop the skills necessary to complete simple home improvement projects, create tutorials and guides for beginners, explain complex concepts in layman’s terms using visuals, and work on developing helpful resources that people can use when taking on their own do-it-yourself project. My first suggestion request is:\n\n[request]",
"example": "I need help on creating an outdoor seating area for entertaining guests."
},
{
"id": "builtin_prompt_36",
"name": "Regex Generator",
"category": "other",
"content": "I want you to act as a regex generator. Your role is to generate regular expressions that match specific patterns in text. You should provide the regular expressions in a format that can be easily copied and pasted into a regex-enabled text editor or programming language. Do not write explanations or examples of how the regular expressions work; simply provide only the regular expressions themselves. My first prompt is:\n\n[prompt]",
"example": "Generate a regular expression that matches an email address."
},
{
"id": "builtin_prompt_37",
"name": "Architect Guide",
"category": "development",
"content": "You are the “Architect Guide,” specialized in assisting programmers who are experienced in individual module development but are looking to enhance their skills in understanding and managing entire project architectures. Your primary roles and methods of guidance include:\n- Basics of Project Architecture: Start with foundational knowledge, focusing on principles and practices of inter-module communication and standardization in modular coding.\n- Integration Insights: Provide insights into how individual modules integrate and communicate within a larger system, using examples and case studies for effective project architecture demonstration.\n- Exploration of Architectural Styles: Encourage exploring different architectural styles, discussing their suitability for various types of projects, and provide resources for further learning.\n- Practical Exercises: Offer practical exercises to apply new concepts in real-world scenarios. Analysis of Multi-layered Software Projects: Analyze complex software projects to understand their architecture, including layers like Frontend Application, Backend Service, and Data Storage.\n- Educational Insights: Focus on educational insights for comprehensive project development understanding, including reviewing project readme files and source code.\n- Use of Diagrams and Images: Utilize architecture diagrams and images to aid in understanding project structure and layer interactions.\n- Clarity Over Jargon: Avoid overly technical language, focusing on clear, understandable explanations.\n- No Coding Solutions: Focus on architectural concepts and practices rather than specific coding solutions.\n- Detailed Yet Concise Responses: Provide detailed responses that are concise and informative without being overwhelming.\n- Practical Application and Real-World Examples: Emphasize practical application with real-world examples.\n- Clarification Requests: Ask for clarification on vague project details or unspecified architectural styles to ensure accurate advice.\n- Professional and Approachable Tone: Maintain a professional yet approachable tone, using familiar but not overly casual language.\n- Use of Everyday Analogies: When discussing technical concepts, use everyday analogies to make them more accessible and understandable."
},
{
"id": "builtin_prompt_38",
"name": "IT Expert",
"category": "other",
"content": "I want you to act as an IT Expert. I will provide you with all the information needed about my technical problems, and your role is to solve my problem. You should use your computer science, network infrastructure, and IT security knowledge to solve my problem. Using intelligent, simple, and understandable language for people of all levels in your answers will be helpful. It is helpful to explain your solutions step by step and with bullet points. Try to avoid too many technical details, but use them when necessary. I want you to reply with the solution, not write any explanations. My first problem is:\n\n[problem]",
"example": "My laptop gets an error with a blue screen."
},
{
"id": "builtin_prompt_39",
"name": "Senior Devops Engineer",
"category": "development",
"content": "You are a Senior DevOps Engineer planning the initial DevOps setup for a new web application. First, gather the business requirements from stakeholders. Then, create a simplified DevOps plan covering these key areas:\n\nI. Infrastructure:\n\n Cloud Provider (AWS, Azure, GCP, etc.): Recommendation and justification.\n IaC Tool (Terraform, etc.): Choice and essential resources managed.\n Database: Selection of a scalable, cost-effective option.\n Caching: Basic caching strategy.\n\nII. Deployment:\n\n Docker: Containerization approach.\n Orchestration (Kubernetes, ECS, Docker Compose): Choice for application needs.\n CI/CD Pipeline: Automated build, test, and deployment strategy.\n\nIII. Automation:\n\n CI/CD Tool (Jenkins, GitLab CI, etc.): Selection and rationale.\n Monitoring/Alerting: Basic implementation; define key metrics.\n Logging: Centralized solution.\n\nIV. Scaling, Cost, & Security:\n\n Auto-Scaling: Implementation approach.\n Load Balancing: Configuration.\n Resource Optimization: Strategy.\n Cost Monitoring: Tools for tracking expenses.\n Secrets Management: Solution.\n Least Privilege: Enforcement.\n WAF: Consideration.\n\nDeliverable:\n\nA concise DevOps plan outlining technologies and processes for deploying and managing the web application, directly addressing the business requirements gathered from stakeholders. Focus on actionable steps and cost optimization.\n\nThis is my initial list of requirements for the Devops plan:\n\n[list of requirements]",
"example": "I want to build a new e-commerce MVP, targeting rapid deployment, cost-effective scalability for 1,000 daily active users (DAU) in the first month, and future growth"
},
{
"id": "builtin_prompt_40",
"name": "Software Quality Assurance Tester",
"category": "development",
"content": "I want you to act as a software quality assurance tester for a new software application. Your job is to test the functionality and performance of the software to ensure it meets the required standards. You will need to write detailed reports on any issues or bugs you encounter, and provide recommendations for improvement. Do not include any personal opinions or subjective evaluations in your reports. Your first task is:\n\n[task]",
"example": "Test the login functionality of the software, including valid and invalid credentials, password recovery, and session management."
},
{
"id": "builtin_prompt_41",
"name": "Developer Relationsn Consultant",
"category": "development",
"content": "I want you to act as a Developer Relations consultant. I will provide you with a software package and it’s related documentation. Research the package and its available documentation, and if none can be found, reply “Unable to find docs”. Your feedback needs to include quantitative analysis (using data from StackOverflow, Hacker News, and GitHub) of content like issues submitted, closed issues, number of stars on a repository, and overall StackOverflow activity. If there are areas that could be expanded on, include scenarios or contexts that should be added. Include specifics of the provided software packages like number of downloads, and related statistics over time. You should compare industrial competitors and the benefits or shortcomings when compared with the package. Approach this from the mindset of the professional opinion of software engineers. Review technical blogs and websites (such as TechCrunch.com or Crunchbase.com) and if data isn’t available, reply “No data available”.",
"example": "I want you to analyze the software package Express at https://expressjs.com” Provide a detailed report on its usage, popularity, and areas for improvement."
},
{
"id": "builtin_prompt_42",
"name": "Commit Message Generator",
"category": "development",
"content": "I want you to act as a commit message generator. I will provide you with information about the task and the prefix for the task code, and I would like you to generate an appropriate commit message using the conventional commit format. Do not write any explanations or other words, just reply with the commit message.",
"example": "Task: Add a new feature to the user profile page\nPrefix: feat\nCommit Message: feat(user-profile): add new feature to display user bio"
},
{
"id": "builtin_prompt_43",
"name": "Code Reviewer",
"category": "development",
"content": "I want you to act as a code reviewer, who can thoroughly examine and evaluate code submissions, identify potential issues, and provide constructive feedback to improve code quality, maintainability, and performance. Share insights on adhering to coding standards, optimizing algorithms, and implementing best practices for error handling, security, and resource management. Offer guidance on enhancing code readability, documentation, and modularity to ensure a robust and maintainable codebase. My first request is:\n\n[request]",
"example": "Review this code snippet for a Python function that calculates the factorial of a number and suggest improvements."
},
{
"id": "builtin_prompt_44",
"name": "Full Stack Developer",
"category": "development",
"content": "I want you to act as a full-stack software developer, who can provide guidance on designing, developing, and deploying full-stack applications. Share insights on working with various front-end technologies (like HTML, CSS, JavaScript, and frameworks like React or Vue.js), back-end technologies (like Node.js, Python, or Ruby), and databases (like SQL or MongoDB). Offer advice on managing client-server communication, implementing user authentication, handling errors, and deploying applications to the cloud. My first request is:\n\n[request]",
"example": "Help me design a simple web application that allows users to create and manage to-do lists, including user authentication and data storage."
},
{
"id": "builtin_prompt_45",
"name": "Code Teacher",
"category": "development",
"content": "I want you to act as a code teacher, who can provide clear and concise explanations of programming concepts, techniques, and best practices across various languages, such as Python, JavaScript, Java, or C++. Share insights on understanding fundamental programming principles, mastering specific languages, and utilizing essential tools and resources to enhance learning. Offer guidance on building projects, honing problem-solving skills, and staying up-to-date with the latest trends and developments in the software industry. My first request is:\n\n[request]",
"example": "Explain the concept of object-oriented programming and how it is implemented in Python, using an example to illustrate the key concepts of classes, objects, inheritance, and polymorphism."
},
{
"id": "builtin_prompt_46",
"name": "Machine Learning Engineer",
"category": "development",
"content": "I want you to act as a machine learning engineer, who can provide insights into the process of developing machine learning models. Share knowledge about data preparation, feature engineering, model selection, training, and evaluation. Discuss the nuances of various machine learning algorithms and their use cases. Also, offer advice on how to manage overfitting, interpret model performance, and improve predictions. My first request is:\n\n[request]",
"example": "Provide a step-by-step guide on how to develop a machine learning model to predict house prices based on features like location, number of rooms, and square footage."
},
{
"id": "builtin_prompt_47",
"name": "Coding Assistant",
"category": "development",
"content": "I want you to act as a coding assistant, who can provide guidance, tips, and best practices for various programming languages, such as Python, JavaScript, Java, or C++. Share insights on writing clean, efficient, and well-documented code, as well as debugging and troubleshooting common issues. Offer advice on selecting the appropriate tools, libraries, and frameworks for specific projects, and assist with understanding key programming concepts, such as algorithms, data structures, and design patterns. My first request is :\n\n[request]",
"example": "Help me write a simple Python script that reads a CSV file, filters the data based on specific criteria, and outputs the results to a new CSV file."
},
{
"id": "builtin_prompt_48",
"name": "ASCII Artist",
"category": "other",
"content": "I want you to act as an ascii artist. I will provide or describe an object to you and I will ask you to write that object as ascii code in the code block. Write only ascii code. Do not explain about the object you wrote. The first thing I want you to draw is:\n\n[object]",
"example": "A cat",
"sampleResponse": "```\n /\\_/\\\n( o.o )\n > ^ <\n```"
},
{
"id": "built_in_prompt_49",
"name": "Blog Outline",
"content": "Create a blog outline for [topic].\n\nOptional details: Focus on [angle] to show the reader to do [action].",
"category": "contentSeo"
},
{
"id": "built_in_prompt_50",
"name": "Blog Post",
"content": "Write a blog about [topic]. Make sure to focus on [key points].",
"category": "contentSeo"
},
{
"id": "built_in_prompt_51",
"name": "FAQ Generator",
"content": "Create a list of [10] frequently asked questions about [keyword] and provide answers for each one of them considering the SERP and rich result guidelines.",
"category": "contentSeo"
},
{
"id": "built_in_prompt_52",
"name": "Headline Generator",
"content": "Generate 10 attention-grabbing headlines for an article about [your topic]",
"category": "contentSeo"
},
{
"id": "built_in_prompt_53",
"name": "Landing Page Copy",
"content": "Create website copy about [product details]. Follow the following structure:\n\n- Hero\n- Subheader\n- Call to action\n- Tagline\n- H2\n- paragraph\n- H2\n- paragraph\n- H2\n- paragraph\n- Call to action",
"category": "contentSeo"
},
{
"id": "built_in_prompt_54",
"name": "Product Brochure",
"content": "Create a brochure outlining the features and benefits of [product]. Include customer testimonials and a call to action.\n\nProduct details: [additional product details]",
"category": "contentSeo"
},
{
"id": "built_in_prompt_55",
"name": "Product Description",
"content": "Craft an irresistible product description that highlights the benefits of [your product]",
"category": "contentSeo"
},
{
"id": "built_in_prompt_56",
"name": "Rewrite Content",
"content": "Revise the given content to improve its clarity, conciseness, and overall quality.\n\nInstructions:\n1. Read the original content carefully to understand its message and purpose.\n2. Identify any areas that need improvement, such as grammar, word choice, or sentence structure.\n3. Consider the target audience and adjust the tone, style, and level of formality accordingly.\n4. Rewrite the content, ensuring that the revised version maintains the original meaning and intent.\n5. Use clear and concise language, avoiding unnecessary jargon or complex terms.\n6. Break down lengthy sentences into shorter, more digestible ones.\n7. Check the revised content for coherence and flow, ensuring that the ideas are logically organized.\n8. Proofread the final version to correct any remaining errors or inconsistencies. \n\nOriginal content:\n\n[paste the original content here]",
"category": "contentSeo"
},
{
"id": "built_in_prompt_57",
"name": "SEO Content Brief",
"content": "Create a SEO content brief for [keyword].",
"category": "contentSeo"
},
{
"id": "built_in_prompt_58",
"name": "SEO Keyword Ideas",
"content": "Generate a list of 20 keyword ideas on [topic].\n\nCluster this list of keywords according to funnel stages whether they are top of the funnel, middle of the funnel or bottom of the funnel keywords.",
"category": "contentSeo"
},
{
"id": "built_in_prompt_59",
"name": "Short Summary",
"content": "Write a summary in 50 words that summarizes [topic or keyword].",
"category": "contentSeo"
},
{
"id": "built_in_prompt_60",
"name": "Step-by-Step Guide",
"content": "Create a step by step guide to instruct how to [topic].",
"category": "contentSeo"
},
{
"id": "built_in_prompt_61",
"name": "Article Generator",
"content": "Write an article about [topic].\n\nInclude relevant statistics (add the links of the sources you use) and consider diverse perspectives. Write it in a [X tone] and mention the source links at the end.",
"category": "contentSeo"
},
{
"id": "built_in_prompt_62",
"name": "Attention-Interest-Desire-Action",
"content": "Using the 'Attention-Interest-Desire-Action' framework, write an email marketing campaign that highlights the\n\n[features]\n\nof our [product/service]\n\nand explains how these [advantages]\n\ncan be helpful to [ideal customer persona].\n\nElaborate on the [benefits] of our product and how it can positively impact the reader.",
"category": "emailMarketing"
},
{
"id": "built_in_prompt_63",
"name": "Email Subject Generator",
"content": "Develop five subject lines for a cold email offering your [product or service] to a potential client",
"category": "emailMarketing"
},
{
"id": "built_in_prompt_64",
"name": "Features-Advantages-Benefits",
"content": "Using the 'Features-Advantages-Benefits' framework, please write an email marketing campaign that highlights the [features]\n\nof our [product/service]\n\nand explains how these [advantages]\n\ncan be helpful to [ideal customer persona].\n\nElaborate on the [benefits] of our product and how it can positively impact the reader.",
"category": "emailMarketing"
},
{
"id": "built_in_prompt_65",
"name": "Newsletter From Recent News",
"content": "Find recent news about [topic or event] and then turn it into a long form newsletter consisting of a subject line, intro, body paragraph, bullet point list, and a conclusion.",
"category": "emailMarketing"
},
{
"id": "built_in_prompt_66",
"name": "Newsletter Inspiration",
"content": "What are the top trends in [industry] that I can include in my next newsletter focused on [details about your newsletter]?",
"category": "emailMarketing"
},
{
"id": "built_in_prompt_67",
"name": "Pain-Agitate-Solution",
"content": "Using the 'Pain-Agitate-Solution' framework, please write an email marketing campaign that highlights the [features]\n\nof our [product/service]\n\nand explains how these [advantages]\n\ncan be helpful to [ideal customer persona].\n\nElaborate on the [benefits] of our product and how it can positively impact the reader.",
"category": "emailMarketing"
},
{
"id": "built_in_prompt_68",
"name": "Facebook Ad",
"content": "Create 3 variations of effective ad copy to promote [product] for [audience].\n\nMake sure they are [persuasive/playful/emotional] and mention these benefits:\n- [Benefit 1]\n- [Benefit 2]\n- [Benefit 3] Finish with a call to action saying [CTA] and add 3 emojis to it.",
"category": "paidAds"
},
{
"id": "built_in_prompt_69",
"name": "Facebook Ad (PAS)",
"content": "Product Description: [Product Description]\n\nWrite a PAS for the product\n\nConvert the Problem Agitate Solution into Facebook ad copy\n\nWrite a Facebook ad headline",
"category": "paidAds"
},
{
"id": "built_in_prompt_70",
"name": "Facebook Headlines",
"content": "Brainstorm 20 compelling headlines for a Facebook ad promoting [product description] for [audience]. Format the output as a table.",
"category": "paidAds"
},
{
"id": "built_in_prompt_71",
"name": "Facebook Video Script",
"content": "Write a Facebook ad video script for [product description] that speaks directly to [our target audience] and addresses their pain points and desires",
"category": "paidAds"
},
{
"id": "built_in_prompt_72",
"name": "Google Ads",
"content": "Create 10 google ads (a headline and a description) for [product description] targeting the keyword [keyword].\n\nThe headline of the ad needs to be under 30 characters. The description needs to be under 90 characters. Format the output as a table.",
"category": "paidAds"
},
{
"id": "built_in_prompt_73",
"name": "Customer Case Study",
"content": "Write a customer case study highlighting how [company name] used [product name] to [achieve success]. Include 4 customer quotes, 2 customer success metrics, and visual elements.\n\nCase details:\n[additional details go here]",
"category": "prCommunication"
},
{
"id": "built_in_prompt_74",
"name": "Event Invite",
"content": "Write a persuasive email to increase attendance at our upcoming event about [theme].\n\nHere are additional details about the event to include:\n[event details]",
"category": "prCommunication"
},
{
"id": "built_in_prompt_75",
"name": "Internal Memos",
"content": "Write an internal memo to [specific department/team] regarding [topic].\n\nIn the memo, explain [key details] and the desired outcome. Provide an action plan with clear steps and timeline.",
"category": "prCommunication"
},
{
"id": "built_in_prompt_76",
"name": "Pitch a Journalist",
"content": "Pitch a story to [reporter name/publication].\n\nStory details: [details]",
"category": "prCommunication"
},
{
"id": "built_in_prompt_77",
"name": "Press Release",
"content": "Write a press release for [company or organization] announcing a recent achievement or milestone. Begin with a concise and attention-grabbing headline that summarizes the main news. In the opening paragraph, provide a brief overview of the announcement and its significance. In the following paragraphs, provide more details about the achievement, including any relevant statistics or data. Use quotes from company representatives or experts to add credibility and context to the announcement. End the press release with a brief company description and contact information for media inquiries.\n\nRemember to keep the press release concise, informative, and engaging, highlighting the key takeaways and benefits of the announcement.\n\nPress release details:\n\n[paste any necessary details about the announcement here]",
"category": "prCommunication"
},
{
"id": "built_in_prompt_78",
"name": "Linkedin Connection Invite Message",
"content": "You are a recruiter trying to attract top talent.\n\nYou came across this linkedin profile [linkedin URL].\n\nYou have to pitch them and you can't write more than 500 characters\n\nYour message should include:\n- the name of the company they're currently working for\n- praise for their areas of expertise",
"category": "recruiting"
},
{
"id": "built_in_prompt_79",
"name": "3 Step Outreach Sequence",
"content": "Generate a 3 step outreach sequence for [company URL] selling to [target customer]",
"category": "sales"
},
{
"id": "built_in_prompt_80",
"name": "Analyze Industry Trends",
"content": "Analyze the industry trends for [industry] in the [country/region] for the past 12 months and compare it to the same period last year.\n\nPresent your findings in a report.",
"category": "sales"
},
{
"id": "built_in_prompt_81",
"name": "Brainstorm Pain Points",
"content": "Act as a [target persona].\n\nWhat pain points do they face and what language would they use for [goals]?",
"category": "sales"
},
{
"id": "built_in_prompt_82",
"name": "Competitive Analysis",
"content": "Conduct a full analysis of [competitor company name] and identify the competitive advantages and disadvantages of their product.",
"category": "sales"
},
{
"id": "built_in_prompt_83",
"name": "Linkedin Boolean Search",
"content": "Write me a boolean search on variations of [roles] that I can plug into sales navigator search",
"category": "sales"
},
{
"id": "built_in_prompt_84",
"name": "Personalized Cold Email From LinkedIn Profile",
"content": "Write a personalized cold email to [linkedin profile url] selling [product description].\n\nMake sure the first 7 words of the email catch the reader's attention. Make the email 4-6 sentences.",
"category": "sales"
},
{
"id": "built_in_prompt_85",
"name": "Research Prospect From Linkedin",
"content": "Summarize [linkedin profile url] into 10 bullet points, brainstorm the pain points they have around [topic]",
"category": "sales"
},
{
"id": "builtin_prompt_86",
"name": "Caption Generator",
"content": "Write a catchy caption about [your theme] and try to play with words to make it fun, engage users in the end, ask them questions, use relevant emojis, and 3 hashtags in the end",
"category": "socialMedia"
},
{
"id": "builtin_prompt_87",
"name": "Generate Content Calendar",
"content": "Generate a content calendar about [topic]",
"category": "socialMedia"
},
{
"id": "builtin_prompt_88",
"name": "Headlines",
"content": "Write 5 attention-grabbing headlines for a [platform] post on [topic] for [audience].",
"category": "socialMedia"
},
{
"id": "builtin_prompt_89",
"name": "Instagram Captions",
"content": "Write 5 variations of Instagram captions for [product].\n\nUse friendly, human-like language that appeals to [target audience].\n\nEmphasize the unique qualities of [product],\n\nuse ample emojis, and don't sound too promotional.",
"category": "socialMedia"
},
{
"id": "builtin_prompt_90",
"name": "LinkedIn Post",
"content": "Create a narrative Linkedin post using immersive writing about [topic].\n\nDetails:\n\n[give details in bullet point format]\n\nUse a mix of short and long sentences. Make it punchy and dramatic.",
"category": "socialMedia"
},
{
"id": "builtin_prompt_91",
"name": "TikTok Script",
"content": "Write a super engaging TikTok video script on [topic]. Each sentence should catch the viewer's attention to make them keep watching.",
"category": "socialMedia"
},
{
"id": "builtin_prompt_92",
"name": "Twitter Thread",
"content": "Give a controversial opinion on [topic], then turn it into a twitter thread.",
"category": "socialMedia"
},
{
"id": "builtin_prompt_93",
"name": "Youtube Video Description",
"content": "Write a 100-word YouTube video description that compels [audience]\n\nto watch a video on [topic]\n\nand mentions the following keywords\n\n[keyword 1]\n\n[keyword 2]\n\n[keyword 3].",
"category": "socialMedia"
},
{
"id": "builtin_prompt_94",
"name": "Reframing Business Perspectives",
"content": "Apply Reframing Business Perspectives to analyze [my business decision]. Look at the problem from different angles, challenging the existing beliefs.",
"category": "strategy"
},
{
"id": "builtin_prompt_95",
"name": "Behavioral Economics Principles",
"content": "Apply Behavioral Economics Principles to analyze [my business decision]. Consider how cognitive, emotional, and social factors affect economic decisions.",
"category": "strategy"
},
{
"id": "builtin_prompt_96",
"name": "Blue Ocean Strategy",
"content": "Apply the Blue Ocean Strategy to evaluate [my business decision]. Focus on creating uncontested market space rather than competing in existing industries.",
"category": "strategy"
},
{
"id": "builtin_prompt_97",
"name": "Brand Ecosystem Development",
"content": "Utilize Brand Ecosystem Development to assess [my business decision]. Build a network of interrelated products, services, and stakeholders.",
"category": "strategy"
},
{
"id": "builtin_prompt_98",
"name": "Consumer Behavior Analysis",
"content": "Use Consumer Behavior Analysis to evaluate [my business decision]. Dive into the psychological, personal, and social influences on consumer choices.",
"category": "strategy"
},
{
"id": "builtin_prompt_99",
"name": "Cost-Benefit Analysis",
"content": "Apply Cost-Benefit Analysis to assess [my business decision]. Analyze the expected balance of benefits and costs, including possible risk and uncertainties.",
"category": "strategy"
},
{
"id": "builtin_prompt_100",
"name": "Customer Lifetime Value",
"content": "Assess [my business decision] by considering Customer Lifetime Value. Analyze the long-term value of customers to understand how acquisition, retention, and monetization strategies align.",
"category": "strategy"
},
{
"id": "builtin_prompt_101",
"name": "Customer Persona Building",
"content": "Utilize Customer Persona Building to evaluate [my business decision]. Define specific customer archetypes with detailed attributes and needs.",
"category": "strategy"
},
{
"id": "builtin_prompt_102",
"name": "Disruptive Innovation",
"content": "Apply Disruptive Innovation to assess [my business decision]. Consider how groundbreaking changes in technology or methodology might impact your industry or market.",
"category": "strategy"
},
{
"id": "builtin_prompt_103",
"name": "Double Loop Learning",
"content": "Use Double Loop Learning to evaluate [my business decision]. Reflect not just on solutions, but on underlying assumptions and beliefs, encouraging adaptive learning.",
"category": "strategy"
},
{
"id": "builtin_prompt_104",
"name": "Eisenhower Matrix",
"content": "Use the Eisenhower Matrix to evaluate [my business decision]. Categorize tasks or elements based on urgency and importance to prioritize effectively.",
"category": "strategy"
},
{
"id": "builtin_prompt_105",
"name": "Emotional Intelligence",
"content": "Evaluate [my business decision] with Emotional Intelligence in mind. Recognize and manage both your own and others' emotions to make more empathetic and effective decisions.",
"category": "strategy"
},
{
"id": "builtin_prompt_106",
"name": "Freemium Business Model",
"content": "Apply the Freemium Business Model to [my business decision]. Explore offering basic services for free while charging for premium features.",
"category": "strategy"
},
{
"id": "builtin_prompt_107",
"name": "Heuristics and Decision Trees",
"content": "Evaluate [my business decision] using Heuristics and Decision Trees. Create simplified models to understand complex problems and find optimal paths.",
"category": "strategy"
},
{
"id": "builtin_prompt_108",
"name": "Hyper-Personalization Strategy",
"content": "Use Hyper-Personalization Strategy to evaluate [my business decision]. Leverage data and AI to provide an extremely personalized experience.",
"category": "strategy"
},
{
"id": "builtin_prompt_109",
"name": "Innovation Ambition Matrix",
"content": "Evaluate [my business decision] through the Innovation Ambition Matrix. Plot initiatives on a matrix to balance core enhancements, adjacent opportunities, and transformational innovations.",
"category": "strategy"
},
{
"id": "builtin_prompt_110",
"name": "Jobs to Be Done Framework",
"content": "Assess [my business decision] by applying the Jobs to Be Done Framework. Focus on the problems customers are trying to solve.",
"category": "strategy"
},
{
"id": "builtin_prompt_111",
"name": "Kano Model Analysis",
"content": "Evaluate [my business decision] using the Kano Model Analysis. Prioritize customer needs into basic, performance, and excitement categories.",
"category": "strategy"
},
{
"id": "builtin_prompt_112",
"name": "Lean Startup Principles",
"content": "Apply Lean Startup Principles to [my business decision]. Focus on creating a minimum viable product, measuring its success, and learning from the results.",
"category": "strategy"
},
{
"id": "builtin_prompt_113",
"name": "Long Tail Strategy",
"content": "Analyze [my business decision] focusing on the Long Tail Strategy. Consider how niche markets or products may contribute to overall success.",
"category": "strategy"
},
{
"id": "builtin_prompt_114",
"name": "Network Effects",
"content": "Analyze [my business decision] through the understanding of Network Effects. Consider how the value of a product or service increases as more people use it.",
"category": "strategy"
},
{
"id": "builtin_prompt_115",
"name": "Pre-Mortem Analysis",
"content": "Utilize Pre-Mortem Analysis to assess [my business decision]. Imagine a future failure of the decision and work backward to identify potential causes and mitigation strategies.",
"category": "strategy"
},
{
"id": "builtin_prompt_116",
"name": "Prospect Theory",
"content": "Utilize Prospect Theory to assess [my business decision]. Understand how people perceive gains and losses and how that can influence decision-making.",
"category": "strategy"
},
{
"id": "builtin_prompt_117",
"name": "Pygmalion Effect",
"content": "Apply the Pygmalion Effect to analyze [my business decision]. Recognize how expectations can influence outcomes, both positively and negatively.",
"category": "strategy"
},
{
"id": "builtin_prompt_118",
"name": "Resource-Based View",
"content": "Apply the Resource-Based View to evaluate [my business decision]. Focus on leveraging the company's internal strengths and weaknesses in relation to external opportunities and threats.",
"category": "strategy"
},
{
"id": "builtin_prompt_119",
"name": "Risk-Reward Analysis",
"content": "Analyze [my business decision] through Risk-Reward Analysis. Evaluate the potential risks against the potential rewards to understand the balance and make an informed decision.",
"category": "strategy"
},
{
"id": "builtin_prompt_120",
"name": "Focus on Results",
"content": "Can you provide me with a case study template for [CLIENT NAME]'s [PRODUCT/SERVICE] that showcases the results achieved? Please suggest the key metrics and KPIs that should be included. Use [AGENCY NAME], [RESULTS], and [KEY METRICS] as placeholders for customization.",
"category": "caseStudies"
},
{
"id": "builtin_prompt_121",
"name": "Showcase Digital Transformation",
"content": "How would you structure a case study for [CLIENT NAME] that focuses on their journey of digital transformation? Please suggest the main sections, such as the challenges, the solutions, and the outcomes achieved. Use [AGENCY NAME], [CLIENT NAME], and [OUTCOMES] as placeholders for customization.",
"category": "caseStudies"
},
{
"id": "builtin_prompt_122",
"name": "Highlighting Collaboration",
"content": "Can you generate a case study template for [CLIENT NAME] that highlights their partnership with [PARTNER NAME]? Please include details on the collaboration process, the benefits for both parties, and the outcomes achieved. Use [AGENCY NAME], [CLIENT NAME], and [PARTNER NAME] as placeholders for customization.",
"category": "caseStudies"
},
{
"id": "builtin_prompt_123",
"name": "Achieving Goals",
"content": "How would you structure a case study for [CLIENT NAME] that showcases their success in achieving sustainability goals? Please suggest the main sections, such as the challenges, the solutions, and the impact achieved. Use [AGENCY NAME], [CLIENT NAME], and [IMPACT] as placeholders for customization.",
"category": "caseStudies"
},
{
"id": "builtin_prompt_124",
"name": "Focus on Strategy",
"content": "Can you provide me with a case study template for [CLIENT NAME] that highlights their social media strategy? Please include details on the objectives, the tactics, and the results achieved. Use [AGENCY NAME], [CLIENT NAME], and [RESULTS] as placeholders for customization.",
"category": "caseStudies"
},
{
"id": "builtin_prompt_125",
"name": "Highlight Unique Features",
"content": "Can you write a benefit-driven sales copy that highlights the unique features and benefits of [product/service]?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_126",
"name": "Explain the Benefits",
"content": "How would you describe the benefits of [product/service] to a potential customer in a way that would convince them to purchase?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_127",
"name": "Explain the Need",
"content": "Can you craft a benefit-driven sales copy that explains why [product/service] is the best solution for [customer's problem or need]?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_128",
"name": "Crafting Convincing Copy",
"content": "How would you sell the benefits of [product/service] to a customer who is on the fence about making a purchase?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_129",
"name": "Overcome Objections",
"content": "How would you sell the benefits of [product/service] to a customer who is skeptical about its effectiveness for [customer's problem or need]?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_130",
"name": "Make Your Product Stand Out (Long-Form)",
"content": "Can you write an extended sales copy describing the unique features and benefits of [product/service] that sets it apart from the competition and makes it a must-have for [target audience]?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_131",
"name": "Top Features (Long-Form)",
"content": "Can you write an extended sales copy on what are the top features of [product/service] that [target audience] will love?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_132",
"name": "Explaining the Process (Long-Form)",
"content": "Can you write an extended sales copy explaining in detail the process of using [product/service] and how it can help [target audience] achieve their goals?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_133",
"name": "Differentiating Your Product (Long-Form)",
"content": "Can you write an extended sales copy on what makes [product/service] stand out from similar offerings in the market, and why should [target audience] choose it over the competition?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_134",
"name": "Convincing Sales Copy (Long-Form)",
"content": "Can you write an extended sales copy convincing me why [product/service] is the missing piece [target audience] needs to take their [aspect of life/business] to the next level?",
"category": "salesCopy"
},
{
"id": "builtin_prompt_135",
"name": "Engaging Sales Pitches (Medium-Form)",
"content": "Write a sales pitch for a [product/service] that highlights its unique features and benefits.",
"category": "salesCopy"
},
{
"id": "builtin_prompt_136",
"name": "Persuasive Emails (Medium-Form)",
"content": "Write a persuasive email to a potential customer explaining why they should choose [company name] for their [product/service] needs.",
"category": "salesCopy"
},
{
"id": "builtin_prompt_137",
"name": "Persuasive Blog Posts (Medium-Form)",
"content": "Write a persuasive blog post about the benefits of using [product/service] for [specific industry/target market].",
"category": "salesCopy"
},
{
"id": "builtin_prompt_138",
"name": "Problem-Solving Sales Pitches (Medium-Form)",
"content": "Write a sales pitch for a [product or service] that emphasizes its unique features and benefits. Include specific details about how it can solve a problem or improve the customer's life.",
"category": "salesCopy"
}
]
}
Whitespace-only changes.
File diff suppressed because it is too large. Load diff
@@ -15,7 +15,6 @@ void main() {
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapContinousAnotherWay();
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -1,5 +1,3 @@
import 'anon_user_data_migration_test.dart' as anon_user_test;
void main() async {
anon_user_test.main();
// anon_user_test.main();
}
@@ -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));
@@ -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);
});
});
@@ -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);
});
});
}
@@ -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);
});
});
}
@@ -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());
}
}
@@ -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';
@@ -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);
});
});
}
@@ -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));
});
});
}
@@ -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();
}
@@ -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);
});
});
}
@@ -176,6 +176,7 @@ Future<void> insertOutlineInDocument(WidgetTester tester) async {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_outline.tr(),
offset: 180,
);
await tester.pumpAndSettle();
}
@@ -1,42 +1,166 @@
import 'dart:io';
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:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/emoji/emoji_handler.dart';
import 'package:easy_localization/easy_localization.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';
import '../../shared/keyboard.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Future<void> prepare(WidgetTester tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
}
// May be better to move this to an existing test but unsure what it fits with
group('Keyboard shortcuts related to emojis', () {
testWidgets('cmd/ctrl+alt+e shortcut opens the emoji picker',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await prepare(tester);
final Finder editor = find.byType(AppFlowyEditor);
await tester.tap(editor);
await tester.pumpAndSettle();
expect(find.byType(EmojiHandler), findsNothing);
expect(find.byType(EmojiSelectionMenu), findsNothing);
await FlowyTestKeyboard.simulateKeyDownEvent(
[
Platform.isMacOS
? LogicalKeyboardKey.meta
: LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.keyE,
],
tester: tester,
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyE,
isAltPressed: true,
isMetaPressed: Platform.isMacOS,
isControlPressed: !Platform.isMacOS,
);
await tester.pumpAndSettle(Duration(seconds: 1));
expect(find.byType(EmojiHandler), findsOneWidget);
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
/// press backspace to hide the emoji picker
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
expect(find.byType(EmojiHandler), findsNothing);
});
testWidgets('insert emoji by slash menu', (tester) async {
await prepare(tester);
await tester.editor.showSlashMenu();
/// show emoji picler
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_emoji.tr(),
offset: 100,
);
await tester.pumpAndSettle(Duration(seconds: 1));
expect(find.byType(EmojiHandler), findsOneWidget);
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(firstNode.delta!.toPlainText().contains('😀'), true);
});
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(
WidgetTester tester, {
String? search,
}) async {
await prepare(tester);
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);
});
});
}
@@ -1,13 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
import 'package:appflowy/workspace/presentation/settings/settings_dialog.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';
@@ -38,7 +37,7 @@ void main() {
LocaleKeys.settings_workspacePage_appearance_options_light.tr(),
),
);
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(milliseconds: 250));
themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
expect(themeMode, ThemeMode.light);
@@ -48,7 +47,7 @@ void main() {
LocaleKeys.settings_workspacePage_appearance_options_dark.tr(),
),
);
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(milliseconds: 250));
themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
expect(themeMode, ThemeMode.dark);
@@ -66,10 +65,11 @@ void main() {
],
tester: tester,
);
await tester.pumpAndSettle();
await tester.pumpAndSettle(const Duration(milliseconds: 500));
themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
expect(themeMode, ThemeMode.light);
// disable it temporarily. It works on macOS but not on Linux.
// themeMode = tester.widget<MaterialApp>(appFinder).themeMode;
// expect(themeMode, ThemeMode.light);
});
testWidgets('show or hide home menu', (tester) async {
@@ -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();
@@ -1,12 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_edit_link_widget.dart';
import 'package:appflowy/mobile/presentation/editor/mobile_editor_screen.dart';
import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/appflowy_mobile_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/util.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -15,103 +14,121 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('toolbar menu:', () {
Future<void> createNeaPage(WidgetTester tester) async {
final createPageButton =
find.byKey(BottomNavigationBarItemType.add.valueKey);
await tester.tapButton(createPageButton);
expect(find.byType(MobileDocumentScreen), findsOneWidget);
final editor = find.byType(AppFlowyEditor);
expect(editor, findsOneWidget);
}
const testLink = 'https://appflowy.io/';
group('links', () {
testWidgets('insert links', (tester) async {
await tester.launchInAnonymousMode();
final createPageButton = find.byKey(
BottomNavigationBarItemType.add.valueKey,
);
await tester.tapButton(createPageButton);
expect(find.byType(MobileDocumentScreen), findsOneWidget);
final editor = find.byType(AppFlowyEditor);
expect(editor, findsOneWidget);
await createNeaPage(tester);
await tester.editor.tapLineOfEditorAt(0);
final editorState = tester.editor.getCurrentEditorState();
/// move cursor to content
final root = editorState.document.root;
final lastNode = root.children.lastOrNull;
await editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: lastNode!.path)),
);
await tester.pumpAndSettle();
/// insert two lines of text
const strFirst = 'FirstLine',
strSecond = 'SecondLine',
link = 'google.com';
await editorState.insertTextAtCurrentSelection(strFirst);
await tester.pumpAndSettle();
const strFirst = 'FirstLine', strSecond = 'SecondLine';
await tester.ime.insertText(strFirst);
await editorState.insertNewLine();
await tester.pumpAndSettle();
await editorState.insertTextAtCurrentSelection(strSecond);
await tester.pumpAndSettle();
await tester.ime.insertText(strSecond);
final firstLine = find.text(strFirst, findRichText: true),
secondLine = find.text(strSecond, findRichText: true);
expect(firstLine, findsOneWidget);
expect(secondLine, findsOneWidget);
/// select the first line
await tester.longPress(firstLine);
await tester.doubleTapAt(tester.getCenter(firstLine));
await tester.pumpAndSettle();
/// find aa item and tap it
final aaItem = find.byWidgetPredicate(
(widget) =>
widget is AppFlowyMobileToolbarIconItem &&
widget.icon == FlowySvgs.m_toolbar_aa_m,
);
expect(aaItem, findsOneWidget);
await tester.tapButton(aaItem);
/// find link button and tap it
final linkButton = find.byWidgetPredicate(
(widget) =>
widget is MobileToolbarMenuItemWrapper &&
widget.icon == FlowySvgs.m_toolbar_link_m,
);
expect(linkButton, findsOneWidget);
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// input the link
final linkField = find.byWidgetPredicate(
(w) =>
w is FlowyTextField &&
w.hintText == LocaleKeys.document_inlineLink_url_placeholder.tr(),
);
await tester.enterText(linkField, link);
final textFormField = find.byType(TextFormField);
expect(textFormField, findsNWidgets(2));
final linkField = textFormField.last;
await tester.enterText(linkField, testLink);
await tester.pumpAndSettle();
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_earth_m));
/// complete inputting
/// apply the link
await tester.tapButton(find.text(LocaleKeys.button_done.tr()));
/// do it again
/// select the second line
await tester.longPress(secondLine);
await tester.doubleTapAt(tester.getCenter(secondLine));
await tester.pumpAndSettle();
await tester.tapButton(aaItem);
await tester.tapButton(linkButton);
await tester.enterText(linkField, link);
await tester.enterText(linkField, testLink);
await tester.pumpAndSettle();
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_earth_m));
await tester.tapButton(find.text(LocaleKeys.button_done.tr()));
final firstNode = editorState.getNodeAtPath([0]);
final secondNode = editorState.getNodeAtPath([1]);
Map commonDeltaJson(String insert) => {
"insert": insert,
"attributes": {"href": link},
'insert': insert,
'attributes': {'href': testLink, 'is_page_link': false},
};
expect(
firstNode?.delta?.toJson(),
commonDeltaJson(strFirst),
[commonDeltaJson(strFirst)],
);
expect(
secondNode?.delta?.toJson(),
commonDeltaJson(strSecond),
[commonDeltaJson(strSecond)],
);
});
testWidgets('change a link', (tester) async {
await tester.launchInAnonymousMode();
await createNeaPage(tester);
await tester.editor.tapLineOfEditorAt(0);
final editorState = tester.editor.getCurrentEditorState();
const testText = 'TestText';
await tester.ime.insertText(testText);
final textFinder = find.text(testText, findRichText: true);
/// select the first line
await tester.doubleTapAt(tester.getCenter(textFinder));
await tester.pumpAndSettle();
/// find link button and tap it
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// input the link
final textFormField = find.byType(TextFormField);
expect(textFormField, findsNWidgets(2));
final linkField = textFormField.last;
await tester.enterText(linkField, testLink);
await tester.pumpAndSettle();
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_earth_m));
/// apply the link
await tester.tapButton(find.text(LocaleKeys.button_done.tr()));
/// show edit link menu
await tester.longPress(textFinder);
await tester.pumpAndSettle();
final linkEditMenu = find.byType(MobileBottomSheetEditLinkWidget);
expect(linkEditMenu, findsOneWidget);
/// remove the link
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toJson(), [{'insert': testText}]);
});
});
}
@@ -0,0 +1,59 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_ask_ai_entrance.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_result.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_textfield.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Search test', () {
testWidgets('tap to search page', (tester) async {
await tester.launchInAnonymousMode();
final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m);
await tester.tapButton(searchButton);
///check for UI element
expect(find.byType(MobileSearchAskAiEntrance), findsNothing);
expect(find.byType(MobileSearchRecentList), findsOneWidget);
expect(find.byType(MobileSearchResultList), findsNothing);
/// search for something
final searchTextField = find.descendant(
of: find.byType(MobileSearchTextfield),
matching: find.byType(TextFormField),
);
final query = '$gettingStarted searching';
await tester.enterText(searchTextField, query);
await tester.pumpAndSettle();
expect(find.byType(MobileSearchRecentList), findsNothing);
expect(find.byType(MobileSearchResultList), findsOneWidget);
expect(
find.text(LocaleKeys.search_noResultForSearching.tr(args: [query])),
findsOneWidget,
);
/// clear text
final clearButton = find.byFlowySvg(FlowySvgs.clear_s);
await tester.tapButton(clearButton);
expect(find.byType(MobileSearchRecentList), findsOneWidget);
expect(find.byType(MobileSearchResultList), findsNothing);
/// tap cancel button
final cancelButton = find.text(LocaleKeys.button_cancel.tr());
expect(cancelButton, findsNothing);
await tester.enterText(searchTextField, query);
await tester.pumpAndSettle();
expect(cancelButton, findsOneWidget);
await tester.tapButton(cancelButton);
expect(cancelButton, findsNothing);
});
});
}
@@ -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 {
@@ -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);
@@ -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));
}
@@ -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();
+23 -23
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: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
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
+6 -2
View File
@@ -1,16 +1,20 @@
export 'service/ai_entities.dart';
export 'service/ai_prompt_input_bloc.dart';
export 'service/ai_prompt_selector_cubit.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';
export 'widgets/prompt_input/desktop_prompt_input.dart';
export 'widgets/prompt_input/file_attachment_list.dart';
export 'widgets/prompt_input/layout_define.dart';
export 'widgets/prompt_input/mention_page_bottom_sheet.dart';
export 'widgets/prompt_input/mention_page_menu.dart';
export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/prompt_input_text_controller.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';
@@ -3,6 +3,31 @@ 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:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'ai_entities.g.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({
@@ -83,3 +108,97 @@ enum TextFormat {
};
}
}
enum AiPromptCategory {
@JsonValue("other")
other,
@JsonValue("development")
development,
@JsonValue("writing")
writing,
@JsonValue("healthAndFitness")
healthAndFitness,
@JsonValue("business")
business,
@JsonValue("marketing")
marketing,
@JsonValue("learning")
learning,
@JsonValue("travel")
travel,
@JsonValue("contentSeo")
contentSeo,
@JsonValue("emailMarketing")
emailMarketing,
@JsonValue("paidAds")
paidAds,
@JsonValue("prCommunication")
prCommunication,
@JsonValue("recruiting")
recruiting,
@JsonValue("sales")
sales,
@JsonValue("socialMedia")
socialMedia,
@JsonValue("strategy")
strategy,
@JsonValue("caseStudies")
caseStudies,
@JsonValue("salesCopy")
salesCopy;
String get i18n {
return switch (this) {
other => LocaleKeys.ai_customPrompt_others.tr(),
development => LocaleKeys.ai_customPrompt_development.tr(),
writing => LocaleKeys.ai_customPrompt_writing.tr(),
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
business => LocaleKeys.ai_customPrompt_business.tr(),
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
learning => LocaleKeys.ai_customPrompt_learning.tr(),
travel => LocaleKeys.ai_customPrompt_travel.tr(),
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
paidAds => LocaleKeys.ai_customPrompt_paidAds.tr(),
prCommunication => LocaleKeys.ai_customPrompt_prCommunication.tr(),
recruiting => LocaleKeys.ai_customPrompt_recruiting.tr(),
sales => LocaleKeys.ai_customPrompt_sales.tr(),
socialMedia => LocaleKeys.ai_customPrompt_socialMedia.tr(),
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
};
}
}
@JsonSerializable()
class AiPrompt extends Equatable {
const AiPrompt({
required this.id,
required this.name,
required this.content,
required this.category,
required this.example,
required this.sampleResponse,
required this.isFeatured,
});
factory AiPrompt.fromJson(Map<String, dynamic> json) =>
_$AiPromptFromJson(json);
Map<String, dynamic> toJson() => _$AiPromptToJson(this);
final String id;
final String name;
final String content;
final AiPromptCategory category;
@JsonKey(defaultValue: "")
final String example;
@JsonKey(defaultValue: "")
final String sampleResponse;
@JsonKey(defaultValue: false)
final bool isFeatured;
@override
List<Object?> get props => [id, name, content, category, sampleResponse];
}
@@ -0,0 +1,243 @@
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(AIModelState state);
typedef OnAvailableModelsChangedCallback = void Function(
List<AIModelPB>,
AIModelPB?,
);
/// Represents the state of an AI model
class AIModelState {
const AIModelState({
required this.type,
required this.hintText,
required this.tooltip,
required this.isEditable,
required this.localAIEnabled,
});
final AiType type;
/// The text displayed as placeholder/hint in the input field
/// Shows different messages based on AI state (enabled, initializing, disabled)
final String hintText;
/// Optional tooltip text that appears on hover
/// Provides additional context about the current state of the AI
/// Null when no tooltip should be shown
final String? tooltip;
final bool isEditable;
final bool localAIEnabled;
}
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();
}
AIModelState getState() {
if (UniversalPlatform.isMobile) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
final availableModels = _availableModels;
final localAiState = _localAIState;
if (availableModels == null) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
isEditable: true,
tooltip: null,
localAIEnabled: false,
);
}
if (localAiState == null) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
if (!availableModels.selectedModel.isLocal) {
return AIModelState(
type: AiType.cloud,
hintText: LocaleKeys.chat_inputMessageHint.tr(),
tooltip: null,
isEditable: true,
localAIEnabled: false,
);
}
final editable = localAiState.state == RunningStatePB.Running;
final tooltip = localAiState.enabled
? (editable
? null
: LocaleKeys.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr())
: LocaleKeys.settings_aiPage_keys_localAIDisabledTextFieldPrompt.tr();
final hintText = localAiState.enabled
? (editable
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr())
: LocaleKeys.settings_aiPage_keys_localAIDisabled.tr();
return AIModelState(
type: AiType.local,
hintText: hintText,
tooltip: tooltip,
isEditable: editable,
localAIEnabled: localAiState.enabled,
);
}
(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 state = getState();
for (final callback in _stateChangedCallbacks) {
callback(state);
}
}
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;
}
}
@@ -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,31 +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: (modelState) {
emit(
state.copyWith(
aiType: aiType,
supportChatWithFile: false,
localAIState: localAIState,
editable: editable,
hintText: hintText,
modelState: modelState,
),
);
},
@@ -128,23 +102,19 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
}
void _startListening() {
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(pluginState));
}
aiModelStateNotifier.addListener(
onStateChanged: (modelState) {
add(
AIPromptInputEvent.updateAIState(modelState),
);
},
);
}
void _init() {
AIEventGetLocalAIState().send().fold(
(localAIState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(localAIState));
}
},
Log.error,
final modelState = aiModelStateNotifier.getState();
add(
AIPromptInputEvent.updateAIState(modelState),
);
}
@@ -164,8 +134,10 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateAIState(LocalAIPB localAIState) =
_UpdateAIState;
const factory AIPromptInputEvent.updateAIState(
AIModelState modelState,
) = _UpdateAIState;
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
_ToggleShowPredefinedFormat;
const factory AIPromptInputEvent.updatePredefinedFormat(
@@ -184,35 +156,27 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
@freezed
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required AiType aiType,
required AIModelState modelState,
required bool supportChatWithFile,
required bool showPredefinedFormats,
required PredefinedFormat? predefinedFormat,
required LocalAIPB? localAIState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
required bool editable,
required String hintText,
}) = _AIPromptInputState;
factory AIPromptInputState.initial(PredefinedFormat? format) =>
AIPromptInputState(
aiType: AiType.cloud,
modelState: AIModelState(
type: AiType.cloud,
isEditable: true,
hintText: '',
localAIEnabled: false,
tooltip: null,
),
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;
}
@@ -0,0 +1,206 @@
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'ai_entities.dart';
import 'appflowy_ai_service.dart';
part 'ai_prompt_selector_cubit.freezed.dart';
class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
AiPromptSelectorCubit({
AppFlowyAIService? aiService,
}) : _aiService = aiService ?? AppFlowyAIService(),
super(AiPromptSelectorState.loading()) {
filterTextController.addListener(_filterTextChanged);
_loadPrompts();
}
final AppFlowyAIService _aiService;
final filterTextController = TextEditingController();
final List<AiPrompt> availablePrompts = [];
@override
Future<void> close() async {
filterTextController.dispose();
await super.close();
}
void _loadPrompts() async {
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
final visiblePrompts = _getFilteredPrompts(availablePrompts);
emit(
AiPromptSelectorState.ready(
visiblePrompts: visiblePrompts,
showCustomPrompts: false,
isFeaturedCategorySelected: false,
selectedPromptId: visiblePrompts.firstOrNull?.id,
selectedCategory: null,
favoritePrompts: [],
),
);
}
void selectFeaturedCategory() {
state.maybeMap(
ready: (readyState) {
emit(
readyState.copyWith(
// TODO(RS): Add logic to filter prompts based on the featured category
// visiblePrompts: prompts,
isFeaturedCategorySelected: true,
// selectedPromptId: prompts.firstOrNull?.id,
),
);
},
orElse: () {},
);
}
void selectCategory(AiPromptCategory? category) {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = category == null
? availablePrompts
: availablePrompts.where((prompt) => prompt.category == category);
final prompts = _getFilteredPrompts(unfilteredPrompts);
// Check if the selected prompt is still visible
String? selectedPromptId = prompts.firstOrNull?.id;
if (readyState.selectedPromptId != null &&
prompts.any((prompt) => prompt.id == readyState.selectedPromptId)) {
selectedPromptId = readyState.selectedPromptId;
}
emit(
readyState.copyWith(
visiblePrompts: prompts,
isFeaturedCategorySelected: false,
selectedCategory: category,
selectedPromptId: selectedPromptId,
),
);
},
orElse: () {},
);
}
void selectPrompt(String promptId) {
state.maybeMap(
ready: (readyState) {
final selectedPrompt = readyState.visiblePrompts
.firstWhereOrNull((prompt) => prompt.id == promptId);
if (selectedPrompt != null) {
emit(
readyState.copyWith(selectedPromptId: selectedPrompt.id),
);
}
},
orElse: () {},
);
}
void toggleFavorite(String promptId) {
state.maybeMap(
ready: (readyState) {
final favoritePrompts = [...readyState.favoritePrompts];
if (favoritePrompts.contains(promptId)) {
favoritePrompts.remove(promptId);
} else {
favoritePrompts.add(promptId);
}
emit(
readyState.copyWith(favoritePrompts: favoritePrompts),
);
// TODO(RS): Save the updated favorite prompts to local storage or database
},
orElse: () {},
);
}
void reset() {
filterTextController.clear();
state.maybeMap(
ready: (readyState) {
emit(
readyState.copyWith(
visiblePrompts: availablePrompts,
selectedPromptId: null,
selectedCategory: null,
),
);
},
orElse: () {},
);
}
AiPrompt? get selectedPrompt {
return state.maybeMap(
ready: (readyState) {
return readyState.visiblePrompts.firstWhereOrNull(
(prompt) => prompt.id == readyState.selectedPromptId,
);
},
orElse: () => null,
);
}
void _filterTextChanged() {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = readyState.selectedCategory == null
? availablePrompts
: availablePrompts.where(
(prompt) => prompt.category == readyState.selectedCategory,
);
final filteredPrompts = _getFilteredPrompts(unfilteredPrompts);
emit(
readyState.copyWith(visiblePrompts: filteredPrompts),
);
},
orElse: () {},
);
}
List<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
final filterText = filterTextController.value.text.trim().toLowerCase();
return prompts.where((prompt) {
final content = "${prompt.name} ${prompt.name}".toLowerCase();
return content.contains(filterText);
}).toList();
}
}
@freezed
class AiPromptSelectorState with _$AiPromptSelectorState {
const AiPromptSelectorState._();
const factory AiPromptSelectorState.loading() = _AiPromptSelectorLoadingState;
const factory AiPromptSelectorState.ready({
required List<AiPrompt> visiblePrompts,
required List<String> favoritePrompts,
required bool showCustomPrompts,
required bool isFeaturedCategorySelected,
required AiPromptCategory? selectedCategory,
required String? selectedPromptId,
}) = _AiPromptSelectorReadyState;
bool get isLoading => this is _AiPromptSelectorLoadingState;
bool get isReady => this is _AiPromptSelectorReadyState;
AiPrompt? get selectedPrompt => maybeMap(
ready: (state) => state.visiblePrompts
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
orElse: () => null,
);
AiPromptCategory? get selectedCategory => maybeMap(
ready: (state) => state.selectedCategory,
orElse: () => null,
);
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
@@ -11,10 +12,16 @@ 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:fixnum/fixnum.dart' as fixnum;
import 'package:flutter/services.dart';
import 'ai_entities.dart';
import 'error.dart';
enum LocalAIStreamingState {
notReady,
disabled,
}
abstract class AIRepository {
Future<void> streamCompletion({
String? objectId,
@@ -24,13 +31,22 @@ 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,
});
Future<List<AiPrompt>> getBuiltInPrompts();
void updateFavoritePrompts(List<String> promptIds);
}
class AppFlowyAIService implements AIRepository {
final List<AiPrompt> _builtInPrompts = [];
@override
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
@@ -40,15 +56,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();
@@ -74,28 +95,67 @@ class AppFlowyAIService implements AIRepository {
},
);
}
@override
Future<List<AiPrompt>> getBuiltInPrompts() async {
if (_builtInPrompts.isNotEmpty) {
return _builtInPrompts;
}
final prompts = <AiPrompt>[];
try {
final jsonString =
await rootBundle.loadString('assets/built_in_prompts.json');
// final data = await rootBundle.load('assets/built_in_prompts.json');
// final jsonString = utf8.decode(data.buffer.asUint8List());
final jsonData = json.decode(jsonString) as Map<String, dynamic>;
final promptJson = jsonData['prompts'] as List<dynamic>;
prompts.addAll(
promptJson
.map((e) => AiPrompt.fromJson(e as Map<String, dynamic>))
.toList(),
);
} catch (e) {
Log.error(e);
}
_builtInPrompts.addAll(prompts);
return prompts;
}
@override
void updateFavoritePrompts(List<String> promptIds) {}
}
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 +169,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 +179,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');
}
}
}
@@ -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,
);
}
}
@@ -0,0 +1,184 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AiPromptCategoryList extends StatelessWidget {
const AiPromptCategoryList({
super.key,
required this.padding,
});
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: padding,
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: theme.spacing.s,
children: [
_buildSearchField(context),
_buildFeaturedCategory(context),
const AFDivider(),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_buildCategoryItem(context, null),
_buildCategoryItem(context, AiPromptCategory.development),
_buildCategoryItem(context, AiPromptCategory.writing),
_buildCategoryItem(context, AiPromptCategory.business),
_buildCategoryItem(context, AiPromptCategory.marketing),
_buildCategoryItem(context, AiPromptCategory.learning),
_buildCategoryItem(
context,
AiPromptCategory.healthAndFitness,
),
_buildCategoryItem(context, AiPromptCategory.travel),
_buildCategoryItem(context, AiPromptCategory.contentSeo),
_buildCategoryItem(context, AiPromptCategory.emailMarketing),
_buildCategoryItem(context, AiPromptCategory.paidAds),
_buildCategoryItem(context, AiPromptCategory.prCommunication),
_buildCategoryItem(context, AiPromptCategory.recruiting),
_buildCategoryItem(context, AiPromptCategory.sales),
_buildCategoryItem(context, AiPromptCategory.socialMedia),
_buildCategoryItem(context, AiPromptCategory.strategy),
_buildCategoryItem(context, AiPromptCategory.caseStudies),
_buildCategoryItem(context, AiPromptCategory.salesCopy),
],
),
),
],
),
),
);
}
Widget _buildSearchField(BuildContext context) {
return AFTextField(
groupId: "ai_prompt_category_list",
hintText: "Search",
size: AFTextFieldSize.m,
controller: context.read<AiPromptSelectorCubit>().filterTextController,
autoFocus: true,
);
}
Widget _buildCategoryItem(
BuildContext context,
AiPromptCategory? category,
) {
return AiPromptCategoryItem(
category: category,
onSelect: () {
context.read<AiPromptSelectorCubit>().selectCategory(category);
},
);
}
Widget _buildFeaturedCategory(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) => state.isFeaturedCategorySelected,
orElse: () => false,
);
return AFBaseButton(
onTap: () {
if (!isSelected) {
context.read<AiPromptSelectorCubit>().selectFeaturedCategory();
}
},
builder: (context, isHovering, disabled) {
return Row(
children: [
Text(
LocaleKeys.ai_customPrompt_featured.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return Colors.transparent;
},
);
}
}
class AiPromptCategoryItem extends StatelessWidget {
const AiPromptCategoryItem({
super.key,
required this.category,
required this.onSelect,
});
final AiPromptCategory? category;
final VoidCallback onSelect;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) =>
!state.isFeaturedCategorySelected &&
state.selectedCategory == category,
orElse: () => false,
);
return AFBaseButton(
onTap: onSelect,
builder: (context, isHovering, disabled) {
return Row(
children: [
Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return Colors.transparent;
},
);
}
}
@@ -0,0 +1,152 @@
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_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_category_list.dart';
import 'ai_prompt_preview.dart';
import 'ai_prompt_visible_list.dart';
Future<AiPrompt?> showAiPromptModal(
BuildContext context, {
required AiPromptSelectorCubit aiPromptSelectorCubit,
}) async {
return showDialog<AiPrompt?>(
context: context,
builder: (context) {
return BlocProvider.value(
value: aiPromptSelectorCubit,
child: const AiPromptModal(),
);
},
);
}
class AiPromptModal extends StatelessWidget {
const AiPromptModal({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFModal(
constraints: const BoxConstraints(
maxWidth: 1200,
maxHeight: 800,
),
child: Column(
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.s),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
Expanded(
child: AFModalBody(
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return state.map(
loading: (_) {
return const Center(
child: CircularProgressIndicator(),
);
},
ready: (_) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: AiPromptCategoryList(
padding: EdgeInsetsDirectional.only(
top: theme.spacing.l,
end: theme.spacing.l,
),
),
),
Expanded(
flex: 2,
child: AiPromptVisibleList(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.l,
horizontal: theme.spacing.l,
),
),
),
Expanded(
flex: 3,
child: BlocBuilder<AiPromptSelectorCubit,
AiPromptSelectorState>(
builder: (context, state) {
final selectedPrompt = state.maybeMap(
ready: (state) {
return state.visiblePrompts
.firstWhereOrNull(
(prompt) =>
prompt.id == state.selectedPromptId,
);
},
orElse: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.l,
horizontal: theme.spacing.l,
),
);
},
),
),
],
);
},
);
},
),
),
),
AFModalFooter(
trailing: [
AFFilledTextButton.primary(
text: LocaleKeys.ai_customPrompt_usePrompt.tr(),
onTap: () {
final selectedPrompt =
context.read<AiPromptSelectorCubit>().selectedPrompt;
if (selectedPrompt != null) {
Navigator.of(context).pop(selectedPrompt);
}
},
),
],
),
],
),
);
}
}
@@ -0,0 +1,201 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class AiPromptPreview extends StatelessWidget {
const AiPromptPreview({
super.key,
required this.prompt,
required this.padding,
});
final AiPrompt prompt;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SelectionArea(
child: ListView(
padding: padding,
children: [
SelectionContainer.disabled(
child: Text(
prompt.name,
style: theme.textStyle.headline.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_prompt.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptContent(
prompt: prompt,
),
VSpace(theme.spacing.xl),
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptExample(
prompt: prompt,
),
VSpace(theme.spacing.xl),
],
if (prompt.sampleResponse.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_sampleOutput.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
),
VSpace(theme.spacing.xs),
_PromptSampleOutput(
prompt: prompt,
),
],
],
),
);
}
}
class _PromptContent extends StatelessWidget {
const _PromptContent({
required this.prompt,
});
final AiPrompt prompt;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final textSpans = _buildTextSpans(context, prompt.content);
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: Text.rich(
TextSpan(
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
children: textSpans,
),
),
);
}
List<TextSpan> _buildTextSpans(BuildContext context, String text) {
final theme = AppFlowyTheme.of(context);
final spans = <TextSpan>[];
final parts = _splitPromptText(text);
for (final part in parts) {
if (part.startsWith('[') && part.endsWith(']')) {
spans.add(
TextSpan(
text: part,
style: TextStyle(color: theme.textColorScheme.purple),
),
);
} else {
spans.add(TextSpan(text: part));
}
}
return spans;
}
List<String> _splitPromptText(String text) {
final regex = RegExp(r'(\[[^\[\]]*?\])');
final result = <String>[];
text.splitMapJoin(
regex,
onMatch: (match) {
result.add(match.group(0)!);
return '';
},
onNonMatch: (nonMatch) {
result.add(nonMatch);
return '';
},
);
return result;
}
}
class _PromptExample extends StatelessWidget {
const _PromptExample({
required this.prompt,
});
final AiPrompt prompt;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
markdown: prompt.example,
),
);
}
}
class _PromptSampleOutput extends StatelessWidget {
const _PromptSampleOutput({
required this.prompt,
});
final AiPrompt prompt;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
markdown: prompt.sampleResponse,
),
);
}
}
@@ -0,0 +1,244 @@
import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:diffutil_dart/diffutil.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
class AiPromptVisibleList extends StatefulWidget {
const AiPromptVisibleList({
super.key,
required this.padding,
});
final EdgeInsetsGeometry padding;
@override
State<AiPromptVisibleList> createState() => _AiPromptVisibleListState();
}
class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
final listKey = GlobalKey<AnimatedListState>();
final scrollController = ScrollController();
final List<AiPrompt> oldList = [];
@override
void initState() {
super.initState();
final prompts = context.read<AiPromptSelectorCubit>().state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
oldList.addAll(prompts);
}
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final spacing = AppFlowyTheme.of(context).spacing;
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
final list = state.maybeMap(
ready: (state) => state.visiblePrompts,
orElse: () => <AiPrompt>[],
);
handleVisiblePromptListChanged(list);
},
child: AnimatedList(
controller: scrollController,
padding: widget.padding,
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
final cubit = context.read<AiPromptSelectorCubit>();
return cubit.state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : spacing.s,
bottom:
index == state.visiblePrompts.length - 1 ? 0 : spacing.s,
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
);
}
void handleVisiblePromptListChanged(
List<AiPrompt> newList,
) {
final updates = calculateListDiff(oldList, newList).getUpdatesWithData();
for (final update in updates) {
update.when(
insert: (pos, data) {
listKey.currentState?.insertItem(
pos,
duration: _listItemAnimationDuration,
);
},
remove: (pos, data) {
listKey.currentState?.removeItem(
pos,
(context, animation) {
final isSelected =
context.read<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) => state.selectedPromptId == data.id,
orElse: () => false,
);
return _AiPromptListItem(
animation: animation,
prompt: data,
isSelected: isSelected,
);
},
duration: _listItemAnimationDuration,
);
},
change: (pos, oldData, newData) {},
move: (from, to, data) {},
);
}
oldList
..clear()
..addAll(newList);
}
}
class _AiPromptListItem extends StatefulWidget {
const _AiPromptListItem({
required this.animation,
required this.prompt,
required this.isSelected,
});
final Animation<double> animation;
final AiPrompt prompt;
final bool isSelected;
@override
State<_AiPromptListItem> createState() => _AiPromptListItemState();
}
class _AiPromptListItemState extends State<_AiPromptListItem> {
bool isHovering = false;
Timer? timer;
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final cubit = context.read<AiPromptSelectorCubit>();
final curvedAnimation = CurvedAnimation(
parent: widget.animation,
curve: Curves.easeIn,
);
return FadeTransition(
opacity: curvedAnimation,
child: SizeTransition(
sizeFactor: curvedAnimation,
child: MouseRegion(
onEnter: (_) {
setState(() {
isHovering = true;
timer = Timer(
const Duration(milliseconds: 300),
() {
if (isHovering) {
cubit.selectPrompt(widget.prompt.id);
}
},
);
});
},
onExit: (_) {
setState(() {
isHovering = false;
timer?.cancel();
timer = null;
});
},
child: GestureDetector(
onTap: () {
cubit.selectPrompt(widget.prompt.id);
},
onDoubleTap: () {
Navigator.of(context).pop(widget.prompt);
},
child: Container(
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
border: Border.all(
color: widget.isSelected
? isHovering
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
widget.prompt.name,
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
),
],
),
Text(
widget.prompt.content,
maxLines: 2,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
softWrap: true,
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.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';
import '../ai_prompt_modal/ai_prompt_modal.dart';
class BrowsePromptsButton extends StatelessWidget {
const BrowsePromptsButton({
super.key,
required this.onSelectPrompt,
});
final void Function(AiPrompt) onSelectPrompt;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.ai_customPrompt_browsePrompts.tr(),
child: BlocProvider(
create: (context) => AiPromptSelectorCubit(),
child: Builder(
builder: (context) {
return GestureDetector(
onTap: () async {
final prompt = await showAiPromptModal(
context,
aiPromptSelectorCubit: context.read<AiPromptSelectorCubit>(),
);
if (context.mounted) {
context.read<AiPromptSelectorCubit>().reset();
}
if (prompt != null && context.mounted) {
onSelectPrompt(prompt);
}
},
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Center(
child: FlowyText(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
);
},
),
),
);
}
}
@@ -1,36 +1,42 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'browse_prompts_button.dart';
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 AiPromptInputTextEditingController 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(
@@ -147,7 +154,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
start: 8.0,
),
child: ChangeFormatBar(
showImageFormats: state.aiType.isCloud,
showImageFormats:
state.modelState.type == AiType.cloud,
predefinedFormat: state.predefinedFormat,
spacing: 4.0,
onSelectPredefinedFormat: (format) =>
@@ -163,8 +171,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 +187,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
widget.selectedSourcesNotifier,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
onSelectPrompt: handleOnSelectPrompt,
extraBottomActionButton:
widget.extraBottomActionButton,
),
),
),
@@ -216,12 +228,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 +249,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 +261,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 +286,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 +304,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 +351,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,
@@ -376,20 +394,18 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
builder: (context, state) {
Widget textField = PromptInputTextField(
key: textFieldKey,
editable: state.editable,
editable: state.modelState.isEditable,
cubit: inputControlCubit,
textController: textController,
textController: widget.textController,
textFieldFocusNode: focusNode,
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
hintText: state.hintText,
hintText: state.modelState.hintText,
);
if (!state.editable) {
if (state.modelState.tooltip != null) {
textField = FlowyTooltip(
message: LocaleKeys
.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr(),
message: state.modelState.tooltip!,
child: textField,
);
}
@@ -478,6 +494,28 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
),
};
}
void handleOnSelectPrompt(AiPrompt prompt) {
final bloc = context.read<AIPromptInputBloc>();
bloc.add(
AIPromptInputEvent.updateMentionedViews([]),
);
final content = AiPromptInputTextEditingController.replace(prompt.content);
widget.textController.value = TextEditingValue(
text: content,
selection: TextSelection.collapsed(
offset: content.length,
),
);
if (bloc.state.showPredefinedFormats) {
bloc.add(
AIPromptInputEvent.toggleShowPredefinedFormat(),
);
}
}
}
class _SubmitOrMentionPageIntent extends Intent {
@@ -516,7 +554,9 @@ class PromptInputTextField extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ExtendedTextField(
final theme = AppFlowyTheme.of(context);
return TextField(
controller: textController,
focusNode: textFieldFocusNode,
readOnly: !editable,
@@ -535,19 +575,14 @@ class PromptInputTextField extends StatelessWidget {
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: PromptInputTextSpanBuilder(
inputControlCubit: cubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
);
}
TextStyle? inputHintTextStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(
return AppFlowyTheme.of(context).textStyle.body.standard(
color: Theme.of(context).isLightMode
? const Color(0xFFBDC2C8)
: const Color(0xFF3C3E51),
@@ -558,16 +593,20 @@ 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,
required this.onSelectPrompt,
this.extraBottomActionButton,
});
final bool showPredefinedFormats;
final bool showPredefinedFormatBar;
final bool showPredefinedFormatButton;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
@@ -575,6 +614,8 @@ class _PromptBottomActions extends StatelessWidget {
final void Function() onStopStreaming;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final void Function(AiPrompt) onSelectPrompt;
final Widget? extraBottomActionButton;
@override
Widget build(BuildContext context) {
@@ -583,32 +624,19 @@ 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(
spacing: DesktopAIChatSizes.inputActionBarButtonSpacing,
children: [
_predefinedFormatButton(),
if (showPredefinedFormatButton) _predefinedFormatButton(),
_selectModelButton(context),
_buildBrowsePromptsButton(),
const Spacer(),
if (state.aiType.isCloud) ...[
_selectSourcesButton(context),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (state.modelState.type == AiType.cloud) _selectSourcesButton(),
if (extraBottomActionButton != null) extraBottomActionButton!,
// _mentionButton(context),
// const HSpace(
// DesktopAIPromptSizes.actionBarButtonSpacing,
// ),
if (state.supportChatWithFile) ...[
_attachmentButton(context),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (state.supportChatWithFile) _attachmentButton(context),
_sendButton(),
],
);
@@ -619,18 +647,31 @@ 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,
);
}
Widget _selectModelButton(BuildContext context) {
return SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
);
}
Widget _buildBrowsePromptsButton() {
return BrowsePromptsButton(
onSelectPrompt: onSelectPrompt,
);
}
// Widget _mentionButton(BuildContext context) {
// return PromptInputMentionButton(
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
@@ -8,11 +8,11 @@ import 'package:flutter/material.dart';
class PromptInputTextSpanBuilder extends SpecialTextSpanBuilder {
PromptInputTextSpanBuilder({
required this.inputControlCubit,
this.specialTextStyle,
this.mentionedPageTextStyle,
});
final ChatInputControlCubit inputControlCubit;
final TextStyle? specialTextStyle;
final TextStyle? mentionedPageTextStyle;
@override
SpecialText? createSpecialText(
@@ -25,23 +25,22 @@ class PromptInputTextSpanBuilder extends SpecialTextSpanBuilder {
return null;
}
if (!isStart(flag, AtText.flag)) {
return null;
if (isStart(flag, MentionedPageText.flag)) {
return MentionedPageText(
inputControlCubit,
mentionedPageTextStyle ?? textStyle,
onTap,
// scrubbing over text is kinda funky
start: index! - (MentionedPageText.flag.length - 1),
);
}
// index is at the end of the start flag, so the start index should be index - (flag.length - 1)
return AtText(
inputControlCubit,
specialTextStyle ?? textStyle,
onTap,
// scrubbing over text is kinda funky
start: index! - (AtText.flag.length - 1),
);
return null;
}
}
class AtText extends SpecialText {
AtText(
class MentionedPageText extends SpecialText {
MentionedPageText(
this.inputControlCubit,
TextStyle? textStyle,
SpecialTextGestureTapCallback? onTap, {
@@ -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(
@@ -0,0 +1,78 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/widgets.dart';
final openingBracketReplacement = String.fromCharCode(0xFFFE);
final closingBracketReplacement = String.fromCharCode(0xFFFD);
class AiPromptInputTextEditingController extends TextEditingController {
AiPromptInputTextEditingController();
static String replace(String text) {
return text
.replaceAll('[', openingBracketReplacement)
.replaceAll(']', closingBracketReplacement);
}
static String restore(String text) {
return text
.replaceAll(openingBracketReplacement, '[')
.replaceAll(closingBracketReplacement, ']');
}
void usePrompt(String content) {
value = TextEditingValue(
text: content,
selection: TextSelection.collapsed(
offset: content.length,
),
);
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
return TextSpan(
style: style,
children: <InlineSpan>[...getTextSpans(context)],
);
}
Iterable<TextSpan> getTextSpans(BuildContext context) {
final open = openingBracketReplacement;
final close = closingBracketReplacement;
final regex = RegExp('($open[^$open$close]*?$close)');
final theme = AppFlowyTheme.of(context);
final result = <TextSpan>[];
text.splitMapJoin(
regex,
onMatch: (match) {
final string = match.group(0)!;
result.add(
TextSpan(
text: restore(string),
style: theme.textStyle.body.standard().copyWith(
color: theme.textColorScheme.purple,
backgroundColor: theme.fillColorScheme.purpleLight,
),
),
);
return '';
},
onNonMatch: (nonMatch) {
result.add(
TextSpan(
text: restore(nonMatch),
),
);
return '';
},
);
return result;
}
}
@@ -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),
),
],
),
),
),
),
),
),
);
}
}
@@ -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),
),
],
),
@@ -44,17 +44,27 @@ Future<bool> afLaunchUri(
uri = Uri.parse('https://$url');
}
// 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;
/// opening an incorrect link will cause a system error dialog to pop up on macOS
/// only use [canLaunchUrl] on macOS
/// and there is an known issue with url_launcher on Linux where it fails to launch
/// see https://github.com/flutter/flutter/issues/88463
bool result = true;
if (UniversalPlatform.isMacOS) {
result = await launcher.canLaunchUrl(uri);
}
if (result) {
try {
// try to launch the uri directly
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 +143,6 @@ Future<bool> _afLaunchLocalUri(
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
@@ -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(
@@ -43,6 +43,7 @@ class NotificationReminderBloc
createdAt: createdAt,
pageTitle: '',
reminderContent: '',
isLocked: false,
status: NotificationReminderStatus.error,
),
);
@@ -57,6 +58,7 @@ class NotificationReminderBloc
NotificationReminderState(
createdAt: createdAt,
pageTitle: view.nameOrDefault,
isLocked: view.isLocked,
view: view,
reminderContent: node.delta?.toPlainText() ?? '',
nodes: [node],
@@ -70,6 +72,7 @@ class NotificationReminderBloc
NotificationReminderState(
createdAt: createdAt,
pageTitle: view.nameOrDefault,
isLocked: view.isLocked,
view: view,
reminderContent: reminder.message,
status: NotificationReminderStatus.loaded,
@@ -203,6 +206,7 @@ class NotificationReminderState with _$NotificationReminderState {
required String createdAt,
required String pageTitle,
required String reminderContent,
required bool isLocked,
@Default(NotificationReminderStatus.initial)
NotificationReminderStatus status,
@Default([]) List<Node> nodes,
@@ -215,5 +219,6 @@ class NotificationReminderState with _$NotificationReminderState {
createdAt: '',
pageTitle: '',
reminderContent: '',
isLocked: false,
);
}
@@ -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;
}
@@ -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(),
);
}
@@ -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(),
);
@@ -1,22 +1,37 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_header.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_search_text_field.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
class MobileBottomSheetEditLinkWidget extends StatefulWidget {
const MobileBottomSheetEditLinkWidget({
super.key,
required this.text,
required this.href,
required this.onEdit,
required this.linkInfo,
required this.onApply,
required this.onRemoveLink,
required this.currentViewId,
required this.onDispose,
});
final String text;
final String? href;
final void Function(String text, String href) onEdit;
final LinkInfo linkInfo;
final ValueChanged<LinkInfo> onApply;
final ValueChanged<LinkInfo> onRemoveLink;
final VoidCallback onDispose;
final String currentViewId;
@override
State<MobileBottomSheetEditLinkWidget> createState() =>
@@ -25,80 +40,367 @@ class MobileBottomSheetEditLinkWidget extends StatefulWidget {
class _MobileBottomSheetEditLinkWidgetState
extends State<MobileBottomSheetEditLinkWidget> {
late final TextEditingController textController;
late final TextEditingController hrefController;
ValueChanged<LinkInfo> get onApply => widget.onApply;
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
late TextEditingController linkNameController =
TextEditingController(text: linkInfo.name);
final textFocusNode = FocusNode();
late LinkInfo linkInfo = widget.linkInfo;
late LinkSearchTextField searchTextField;
bool isShowingSearchResult = false;
ViewPB? currentView;
bool showErrorText = false;
bool showRemoveLink = false;
AppFlowyThemeData get theme => AppFlowyTheme.of(context);
@override
void initState() {
super.initState();
textController = TextEditingController(
text: widget.text,
);
hrefController = TextEditingController(
text: widget.href,
);
final isPageLink = linkInfo.isPage;
if (isPageLink) getPageView();
searchTextField = LinkSearchTextField(
initialSearchText: isPageLink ? '' : linkInfo.link,
initialViewId: linkInfo.viewId,
currentViewId: widget.currentViewId,
onEnter: () {},
onEscape: () {},
onDataRefresh: () {
if (mounted) setState(() {});
},
)..searchRecentViews();
if (linkInfo.link.isEmpty) {
isShowingSearchResult = true;
} else {
showRemoveLink = true;
textFocusNode.requestFocus();
}
textFocusNode.addListener(() {
if (!mounted) return;
if (textFocusNode.hasFocus) {
setState(() {
isShowingSearchResult = false;
});
}
});
}
@override
void dispose() {
textController.dispose();
hrefController.dispose();
linkNameController.dispose();
textFocusNode.dispose();
searchTextField.dispose();
widget.onDispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: SingleChildScrollView(
child: Column(
children: [
BottomSheetHeader(
title: LocaleKeys.editor_editLink.tr(),
onClose: () => context.pop(),
confirmButton: FlowyTextButton(
LocaleKeys.button_done.tr(),
constraints:
const BoxConstraints.tightFor(width: 62, height: 30),
padding: const EdgeInsets.only(left: 12),
fontColor: theme.textColorScheme.onFill,
fillColor: Theme.of(context).primaryColor,
onPressed: () {
if (isShowingSearchResult) {
onConfirm();
return;
}
if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) {
setState(() {
showErrorText = true;
});
return;
}
widget.onApply.call(linkInfo);
context.pop();
},
),
),
const VSpace(20.0),
buildNameTextField(),
const VSpace(16.0),
buildLinkField(),
const VSpace(20.0),
buildRemoveLink(),
],
),
),
);
}
Widget buildNameTextField() {
return SizedBox(
height: 48,
child: TextFormField(
focusNode: textFocusNode,
textAlign: TextAlign.left,
controller: linkNameController,
style: TextStyle(
fontSize: 16,
height: 20 / 16,
fontWeight: FontWeight.w400,
),
onChanged: (text) {
linkInfo = LinkInfo(
name: text,
link: linkInfo.link,
isPage: linkInfo.isPage,
);
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
contentPadding: EdgeInsets.all(14),
context,
),
),
);
}
Widget buildLinkField() {
final width = MediaQuery.of(context).size.width;
final showPageView = linkInfo.isPage && !isShowingSearchResult;
Widget child;
if (showPageView) {
child = buildPageView();
} else if (!isShowingSearchResult) {
child = buildLinkView();
} else {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
searchTextField.buildTextField(
autofocus: true,
context: context,
contentPadding: EdgeInsets.all(14),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
width: width - 32,
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
BottomSheetHeader(
title: LocaleKeys.editor_editLink.tr(),
onClose: () => context.pop(),
onDone: () {
widget.onEdit(textController.text, hrefController.text);
},
),
const VSpace(20.0),
_buildTextField(
textController,
LocaleKeys.document_inlineLink_title_placeholder.tr(),
),
const VSpace(12.0),
_buildTextField(
hrefController,
LocaleKeys.document_inlineLink_url_placeholder.tr(),
),
const VSpace(12.0),
child,
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: theme.textColorScheme.error,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
}
Widget _buildTextField(
TextEditingController controller,
String? hintText,
) {
return SizedBox(
height: 48.0,
child: FlowyTextField(
controller: controller,
hintText: hintText,
textStyle: const TextStyle(fontSize: 16.0),
hintStyle: const TextStyle(fontSize: 16.0),
suffixIcon: Padding(
padding: const EdgeInsets.all(4.0),
child: FlowyButton(
text: const FlowySvg(
FlowySvgs.close_lg,
),
margin: EdgeInsets.zero,
useIntrinsicWidth: true,
onTap: () {
controller.clear();
},
Widget buildPageView() {
final height = 48.0;
late Widget child;
final view = currentView;
if (view == null) {
child = Center(
child: SizedBox.fromSize(
size: Size(10, 10),
child: CircularProgressIndicator(),
),
);
} else {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
child = GestureDetector(
onTap: showSearchResult,
child: Container(
height: height,
color: Colors.grey.withAlpha(1),
padding: EdgeInsets.all(14),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(4),
Flexible(
child: FlowyText.regular(
displayName,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
),
],
),
),
);
}
return Container(
height: height,
decoration: buildBorderDecoration(),
child: child,
);
}
Widget buildLinkView() {
return Container(
height: 48,
decoration: buildBorderDecoration(),
child: GestureDetector(
onTap: showSearchResult,
child: Padding(
padding: EdgeInsets.all(12),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
),
],
),
),
),
);
}
Widget buildRemoveLink() {
if (!showRemoveLink) return SizedBox.shrink();
return GestureDetector(
onTap: () {
widget.onRemoveLink(linkInfo);
context.pop();
},
child: SizedBox(
height: 32,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.mobile_icon_remove_link_m,
color: theme.iconColorScheme.secondary,
),
HSpace(8),
FlowyText.regular(
LocaleKeys.editor_removeLink.tr(),
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
color: theme.textColorScheme.secondary,
),
],
),
),
),
);
}
void onConfirm() {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () => onPageSelected(searchTextField.currentRecentView),
onSearchViews: () => onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
}
Future<void> onPageSelected(ViewPB view) async {
currentView = view;
final link = ShareConstants.buildShareUrl(
workspaceId: await UserBackendService.getCurrentWorkspace().fold(
(s) => s.id,
(f) => '',
),
viewId: view.id,
);
linkInfo = LinkInfo(
name: linkInfo.name,
link: link,
isPage: true,
);
searchTextField.updateText(linkInfo.link);
if (mounted) {
setState(() {
isShowingSearchResult = false;
searchTextField.unfocus();
});
}
}
void onLinkSelected() {
if (mounted) {
linkInfo = LinkInfo(
name: linkInfo.name,
link: searchTextField.searchText,
);
hideSearchResult();
}
}
void hideSearchResult() {
setState(() {
isShowingSearchResult = false;
searchTextField.unfocus();
textFocusNode.unfocus();
});
}
void showSearchResult() {
setState(() {
if (linkInfo.isPage) searchTextField.updateText('');
isShowingSearchResult = true;
searchTextField.requestFocus();
});
}
Future<void> getPageView() async {
if (!linkInfo.isPage) return;
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(linkInfo.viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
BoxDecoration buildCardDecoration() {
return BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.l),
boxShadow: theme.shadow.medium,
);
}
BoxDecoration buildBorderDecoration() {
return BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.l),
border: Border.all(color: theme.borderColorScheme.greyPrimary),
);
}
}
@@ -8,11 +8,13 @@ class BottomSheetHeader extends StatelessWidget {
this.title,
this.onClose,
this.onDone,
this.confirmButton,
});
final String? title;
final VoidCallback? onClose;
final VoidCallback? onDone;
final Widget? confirmButton;
@override
Widget build(BuildContext context) {
@@ -36,12 +38,10 @@ class BottomSheetHeader extends StatelessWidget {
fontSize: 16,
),
),
if (onDone != null)
if (onDone != null || confirmButton != null)
Align(
alignment: Alignment.centerRight,
child: BottomSheetDoneButton(
onDone: onDone,
),
child: confirmButton ?? BottomSheetDoneButton(onDone: onDone),
),
],
);
@@ -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(),
);
},
@@ -182,7 +182,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
@@ -203,7 +203,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
final userProfile = context.read<MobileViewPageBloc>().state.userProfilePB;
// the publish feature is only available for AppFlowy Cloud
if (userProfile == null ||
userProfile.authenticator != AuthenticatorPB.AppFlowyCloud) {
userProfile.workspaceAuthType != AuthTypePB.Server) {
return [];
}
@@ -236,6 +236,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.unpublish,
),
),
_divider(),
];
} else {
return [
@@ -246,6 +247,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
MobileViewBottomSheetBodyAction.publish,
),
),
_divider(),
];
}
}
@@ -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(),
);
@@ -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})');
@@ -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();
}
@@ -2,10 +2,12 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart';
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/loading.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
@@ -44,9 +46,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 +61,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 +80,7 @@ class MobileHomeScreen extends StatelessWidget {
value: userProfile,
child: MobileHomePage(
userProfile: userProfile,
workspaceSetting: workspaceSetting,
workspaceLatest: workspaceLatest,
),
),
),
@@ -95,11 +97,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();
@@ -145,7 +147,7 @@ class _MobileHomePageState extends State<MobileHomePage> {
void _onLatestViewChange() async {
final id = getIt<MenuSharedState>().latestOpenView?.id;
if (id == null) {
if (id == null || id.isEmpty) {
return;
}
await FolderEventSetLatestView(ViewIdPB(value: id)).send();
@@ -173,7 +175,14 @@ class _HomePageState extends State<_HomePage> {
listener: (context, state) {
getIt<CachedRecentService>().reset();
mCurrentWorkspace.value = state.currentWorkspace;
if (FeatureFlag.search.isOn) {
// Notify command palette that workspace has changed
context.read<CommandPaletteBloc>().add(
CommandPaletteEvent.workspaceChanged(
workspaceId: state.currentWorkspace?.workspaceId,
),
);
}
Debounce.debounce(
'workspace_action_result',
const Duration(milliseconds: 150),
@@ -329,7 +338,7 @@ class _HomePageState extends State<_HomePage> {
}
if (message != null) {
showToastNotification(context, message: message, type: toastType);
showToastNotification(message: message, type: toastType);
}
}
}
@@ -194,6 +194,7 @@ class _MobileWorkspace extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.openWorkspace(
workspace.workspaceId,
workspace.workspaceAuthType,
),
);
},
@@ -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),
],
@@ -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.workspaceAuthType == 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 {
@@ -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(),
);
@@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -82,3 +83,35 @@ class FloatingAIEntry extends StatelessWidget {
);
}
}
class FloatingAIEntryV2 extends StatelessWidget {
const FloatingAIEntryV2({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return GestureDetector(
onTap: () {
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.surfaceColorScheme.primary,
boxShadow: theme.shadow.small,
border: Border.all(color: theme.borderColorScheme.primary),
),
child: Center(
child: FlowySvg(
FlowySvgs.m_home_ai_chat_icon_m,
blendMode: null,
size: Size(24, 24),
),
),
),
);
}
}
@@ -163,17 +163,16 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
case MobileSpaceTabType.recent:
return const MobileRecentSpace();
case MobileSpaceTabType.spaces:
final showAIFloatingButton =
widget.userProfile.workspaceAuthType == AuthTypePB.Server;
return Stack(
children: [
MobileHomeSpace(userProfile: widget.userProfile),
// only show ai chat button for cloud user
if (widget.userProfile.authenticator ==
AuthenticatorPB.AppFlowyCloud)
if (showAIFloatingButton)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 16,
left: 20,
right: 20,
child: const FloatingAIEntry(),
bottom: MediaQuery.of(context).padding.bottom + 16,
child: FloatingAIEntryV2(),
),
],
);
@@ -123,6 +123,7 @@ class _CreateWorkspaceButton extends StatelessWidget {
context.read<UserWorkspaceBloc>().add(
UserWorkspaceEvent.createWorkspace(
name,
AuthTypePB.Server,
),
);
},
@@ -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(
@@ -28,46 +28,52 @@ final PropertyValueNotifier<ViewLayoutPB?> mobileCreateNewPageNotifier =
PropertyValueNotifier(null);
final ValueNotifier<BottomNavigationBarActionType> bottomNavigationBarType =
ValueNotifier(BottomNavigationBarActionType.home);
final ValueNotifier<String?> bottomNavigationBarItemType =
ValueNotifier(BottomNavigationBarItemType.home.label);
enum BottomNavigationBarItemType {
home,
search,
add,
notification;
String get label {
return switch (this) {
BottomNavigationBarItemType.home => 'home',
BottomNavigationBarItemType.add => 'add',
BottomNavigationBarItemType.notification => 'notification',
};
}
String get label => name;
ValueKey get valueKey {
return ValueKey(label);
}
Widget get iconWidget {
return switch (this) {
home => const FlowySvg(FlowySvgs.m_home_unselected_m),
search => const FlowySvg(FlowySvgs.m_home_search_icon_m),
add => const FlowySvg(FlowySvgs.m_home_add_m),
notification => const _NotificationNavigationBarItemIcon(),
};
}
Widget? get activeIcon {
return switch (this) {
home => const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null),
search =>
const FlowySvg(FlowySvgs.m_home_search_icon_active_m, blendMode: null),
add => null,
notification => const _NotificationNavigationBarItemIcon(isActive: true),
};
}
BottomNavigationBarItem get navigationItem {
return BottomNavigationBarItem(
key: valueKey,
label: label,
icon: iconWidget,
activeIcon: activeIcon,
);
}
}
final _items = <BottomNavigationBarItem>[
BottomNavigationBarItem(
key: BottomNavigationBarItemType.home.valueKey,
label: BottomNavigationBarItemType.home.label,
icon: const FlowySvg(FlowySvgs.m_home_unselected_m),
activeIcon: const FlowySvg(FlowySvgs.m_home_selected_m, blendMode: null),
),
BottomNavigationBarItem(
key: BottomNavigationBarItemType.add.valueKey,
label: BottomNavigationBarItemType.add.label,
icon: const FlowySvg(FlowySvgs.m_home_add_m),
),
BottomNavigationBarItem(
key: BottomNavigationBarItemType.notification.valueKey,
label: BottomNavigationBarItemType.notification.label,
icon: const _NotificationNavigationBarItemIcon(),
activeIcon: const _NotificationNavigationBarItemIcon(
isActive: true,
),
),
];
final _items =
BottomNavigationBarItemType.values.map((e) => e.navigationItem).toList();
/// Builds the "shell" for the app by building a Scaffold with a
/// BottomNavigationBar, where [child] is placed in the body of the Scaffold.
@@ -259,6 +265,7 @@ class _HomePageNavigationBar extends StatelessWidget {
} else if (label == BottomNavigationBarItemType.notification.label) {
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
}
bottomNavigationBarItemType.value = label;
// When navigating to a new branch, it's recommended to use the goBranch
// method, as doing so makes sure the last navigation state of the
// Navigator for the branch is restored.
@@ -332,7 +339,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@@ -350,7 +356,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@@ -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>(
@@ -3,11 +3,14 @@ import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'widgets/tab_bar.dart';
final PropertyValueNotifier<List<String>> mSelectedNotificationIds =
PropertyValueNotifier([]);
@@ -76,9 +79,9 @@ class _MobileNotificationsTabState extends State<MobileNotificationsTab>
late TabController tabController;
final tabs = [
MobileNotificationTabType.inbox,
MobileNotificationTabType.unread,
MobileNotificationTabType.archive,
NotificationTabType.inbox,
NotificationTabType.unread,
NotificationTabType.archive,
];
@override
@@ -1,6 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -11,24 +11,24 @@ class EmptyNotification extends StatelessWidget {
required this.type,
});
final MobileNotificationTabType type;
final NotificationTabType type;
@override
Widget build(BuildContext context) {
final title = switch (type) {
MobileNotificationTabType.inbox =>
NotificationTabType.inbox =>
LocaleKeys.settings_notifications_emptyInbox_title.tr(),
MobileNotificationTabType.archive =>
NotificationTabType.archive =>
LocaleKeys.settings_notifications_emptyArchived_title.tr(),
MobileNotificationTabType.unread =>
NotificationTabType.unread =>
LocaleKeys.settings_notifications_emptyUnread_title.tr(),
};
final desc = switch (type) {
MobileNotificationTabType.inbox =>
NotificationTabType.inbox =>
LocaleKeys.settings_notifications_emptyInbox_description.tr(),
MobileNotificationTabType.archive =>
NotificationTabType.archive =>
LocaleKeys.settings_notifications_emptyArchived_description.tr(),
MobileNotificationTabType.unread =>
NotificationTabType.unread =>
LocaleKeys.settings_notifications_emptyUnread_description.tr(),
};
return Column(
@@ -4,6 +4,7 @@ import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/foundation.dart';
@@ -18,7 +19,7 @@ class NotificationItem extends StatelessWidget {
required this.reminder,
});
final MobileNotificationTabType tabType;
final NotificationTabType tabType;
final ReminderPB reminder;
@override
@@ -93,7 +94,7 @@ class _InnerNotificationItem extends StatelessWidget {
required this.tabType,
});
final MobileNotificationTabType tabType;
final NotificationTabType tabType;
final ReminderPB reminder;
@override
@@ -121,22 +122,22 @@ class _SlidableNotificationItem extends StatelessWidget {
required this.child,
});
final MobileNotificationTabType tabType;
final NotificationTabType tabType;
final ReminderPB reminder;
final Widget child;
@override
Widget build(BuildContext context) {
final List<NotificationPaneActionType> actions = switch (tabType) {
MobileNotificationTabType.inbox => [
NotificationTabType.inbox => [
NotificationPaneActionType.more,
if (!reminder.isRead) NotificationPaneActionType.markAsRead,
],
MobileNotificationTabType.unread => [
NotificationTabType.unread => [
NotificationPaneActionType.more,
NotificationPaneActionType.markAsRead,
],
MobileNotificationTabType.archive => [
NotificationTabType.archive => [
if (kDebugMode) NotificationPaneActionType.unArchive,
],
};
@@ -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)',
);
@@ -28,10 +28,36 @@ class NotificationIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const FlowySvg(
FlowySvgs.m_notification_reminder_s,
size: Size.square(_kNotificationIconHeight),
blendMode: null,
return SizedBox(
width: 42,
height: 36,
child: Stack(
children: [
const FlowySvg(
FlowySvgs.m_notification_reminder_s,
size: Size.square(32),
blendMode: null,
),
Align(
alignment: Alignment.bottomRight,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).cardColor,
),
child: Center(
child: FlowyText(
'@',
fontSize: 12,
figmaLineHeight: 14,
),
),
),
),
],
),
);
}
}
@@ -114,16 +140,10 @@ class _NotificationContentState extends State<NotificationContent> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// title
_buildHeader(),
// time & page name
_buildTimeAndPageName(
context,
state.createdAt,
state.pageTitle,
),
// title & time
_buildHeader(state.createdAt, !widget.reminder.isRead),
// page name
_buildPageName(context, state.isLocked, state.pageTitle),
// content
Padding(
padding: const EdgeInsets.only(right: 16.0),
@@ -162,40 +182,65 @@ class _NotificationContentState extends State<NotificationContent> {
return const SizedBox.shrink();
}
Widget _buildHeader() {
return FlowyText.semibold(
LocaleKeys.settings_notifications_titles_reminder.tr(),
fontSize: 14,
figmaLineHeight: 20,
Widget _buildHeader(String createAt, bool unread) {
return SizedBox(
height: 22,
child: Row(
children: [
FlowyText.semibold(
LocaleKeys.settings_notifications_titles_reminder.tr(),
fontSize: 14,
figmaLineHeight: 20,
),
Spacer(),
if (createAt.isNotEmpty)
FlowyText.regular(
createAt,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
if (unread) ...[
HSpace(4),
const UnreadRedDot(),
],
],
),
);
}
Widget _buildTimeAndPageName(
Widget _buildPageName(
BuildContext context,
String createdAt,
bool isLocked,
String pageTitle,
) {
return Opacity(
opacity: 0.5,
child: Row(
children: [
// the legacy reminder doesn't contain the timestamp, so we don't show it
if (createdAt.isNotEmpty) ...[
child: SizedBox(
height: 18,
child: Row(
children: [
/// TODO: need to be replaced after reminder support more types
FlowyText.regular(
createdAt,
LocaleKeys.notificationHub_mentionedYou.tr(),
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
const NotificationEllipse(),
if (isLocked)
Padding(
padding: EdgeInsets.only(right: 5),
child: FlowySvg(FlowySvgs.notification_lock_s),
),
FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
],
FlowyText.regular(
pageTitle,
fontSize: 12,
figmaLineHeight: 18,
color: context.notificationItemTextColor,
),
],
),
),
);
}
@@ -2,12 +2,12 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/notification/notification_reminder_bloc.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -21,7 +21,7 @@ enum NotificationPaneActionType {
MobileSlideActionButton actionButton(
BuildContext context, {
required MobileNotificationTabType tabType,
required NotificationTabType tabType,
}) {
switch (this) {
case NotificationPaneActionType.markAsRead:
@@ -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(),
@@ -3,6 +3,7 @@ import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart'
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/user/application/reminder/reminder_extension.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/appflowy_backend.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@@ -17,7 +18,7 @@ class NotificationTab extends StatefulWidget {
required this.tabType,
});
final MobileNotificationTabType tabType;
final NotificationTabType tabType;
@override
State<NotificationTab> createState() => _NotificationTabState();
@@ -74,7 +75,6 @@ class _NotificationTabState extends State<NotificationTab>
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
);
}
@@ -82,17 +82,17 @@ class _NotificationTabState extends State<NotificationTab>
List<ReminderPB> _filterReminders(List<ReminderPB> reminders) {
switch (widget.tabType) {
case MobileNotificationTabType.inbox:
case NotificationTabType.inbox:
return reminders.reversed
.where((reminder) => !reminder.isArchived)
.toList()
.unique((reminder) => reminder.id);
case MobileNotificationTabType.archive:
case NotificationTabType.archive:
return reminders.reversed
.where((reminder) => reminder.isArchived)
.toList()
.unique((reminder) => reminder.id);
case MobileNotificationTabType.unread:
case NotificationTabType.unread:
return reminders.reversed
.where((reminder) => !reminder.isRead)
.toList()
@@ -1,40 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
import 'package:appflowy/mobile/presentation/notifications/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:flutter/material.dart';
import 'package:reorderable_tabbar/reorderable_tabbar.dart';
enum MobileNotificationTabType {
inbox,
unread,
archive;
String get tr {
switch (this) {
case MobileNotificationTabType.inbox:
return LocaleKeys.settings_notifications_tabs_inbox.tr();
case MobileNotificationTabType.unread:
return LocaleKeys.settings_notifications_tabs_unread.tr();
case MobileNotificationTabType.archive:
return LocaleKeys.settings_notifications_tabs_archived.tr();
}
}
List<NotificationPaneActionType> get actions {
switch (this) {
case MobileNotificationTabType.inbox:
return [
NotificationPaneActionType.more,
NotificationPaneActionType.markAsRead,
];
case MobileNotificationTabType.unread:
case MobileNotificationTabType.archive:
return [];
}
}
}
class MobileNotificationTabBar extends StatelessWidget {
const MobileNotificationTabBar({
super.key,
@@ -44,7 +12,7 @@ class MobileNotificationTabBar extends StatelessWidget {
});
final double height;
final List<MobileNotificationTabType> tabs;
final List<NotificationTabType> tabs;
final TabController tabController;
@override
@@ -6,4 +6,3 @@ export 'settings_popup_menu.dart';
export 'shared.dart';
export 'slide_actions.dart';
export 'tab.dart';
export 'tab_bar.dart';
@@ -0,0 +1,76 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileSearchAskAiEntrance extends StatelessWidget {
const MobileSearchAskAiEntrance({super.key, this.query});
final String? query;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return GestureDetector(
onTap: () {
context
.read<CommandPaletteBloc?>()
?.add(CommandPaletteEvent.gointToAskAI());
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
behavior: HitTestBehavior.opaque,
child: Container(
height: 48,
margin: EdgeInsets.only(top: 16),
padding: EdgeInsets.fromLTRB(8, 12, 8, 12),
child: Row(
children: [
FlowySvg(
FlowySvgs.m_home_ai_chat_icon_m,
size: Size.square(24),
blendMode: null,
),
HSpace(12),
buildText(theme),
],
),
),
);
}
Widget buildText(AppFlowyThemeData theme) {
final queryText = query ?? '';
if (queryText.isEmpty) {
return Text(
LocaleKeys.search_askAIAnything.tr(),
style: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary),
);
}
return Flexible(
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.search_askAIFor.tr(),
style: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary),
),
TextSpan(
text: ' "$queryText"',
style: theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.primary),
),
],
),
),
);
}
}
@@ -0,0 +1,187 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'mobile_view_ancestors.dart';
class MobileSearchResultCell extends StatelessWidget {
const MobileSearchResultCell({super.key, required this.item, this.query});
final SearchResultItem item;
final String? query;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.primary;
final commandPaletteState = context.read<CommandPaletteBloc>().state;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildIcon(),
HSpace(12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.displayName,
normal: theme.textStyle.heading4.standard(color: textColor),
highlight: theme.textStyle.heading4
.standard(color: textColor)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
),
buildPath(commandPaletteState, theme),
buildSummary(theme),
],
),
),
],
),
);
}
Widget buildIcon() {
final icon = item.icon;
if (icon.ty == ResultIconTypePB.Emoji) {
return icon.getIcon(size: 16.0, lineHeight: 20 / 16) ?? SizedBox.shrink();
} else {
return icon.getIcon(size: 20) ?? SizedBox.shrink();
}
}
Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) {
bool isInTrash = false;
for (final view in state.trash) {
if (view.id == item.id) {
isInTrash = true;
break;
}
}
if (isInTrash) {
return Row(
children: [
const FlowySvg(FlowySvgs.trash_s, size: Size.square(20)),
const HSpace(4.0),
Text(
'${LocaleKeys.trash_text.tr()} / ${item.displayName}',
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
),
],
);
} else {
return BlocProvider(
create: (context) => ViewAncestorBloc(item.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
return Text(
displayPath.join(' / '),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
);
},
),
);
}
}
Widget buildSummary(AppFlowyThemeData theme) {
if (item.content.isEmpty) {
return const SizedBox.shrink();
}
return RichText(
maxLines: 3,
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.content,
normal: theme.textStyle.heading4
.standard(color: theme.textColorScheme.secondary),
highlight: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
);
}
TextSpan buildHighLightSpan({
required String content,
required TextStyle normal,
required TextStyle highlight,
}) {
final queryText = (query ?? '').trim();
if (queryText.isEmpty) {
return TextSpan(text: content, style: normal);
}
final contents = content.splitIncludeSeparator(queryText);
return TextSpan(
children: List.generate(contents.length, (index) {
final content = contents[index];
final isHighlight = content.toLowerCase() == queryText.toLowerCase();
return TextSpan(
text: content,
style: isHighlight ? highlight : normal,
);
}),
);
}
}
extension ViewPBToSearchResultItem on ViewPB {
SearchResultItem toSearchResultItem() {
final hasIcon = icon.value.isNotEmpty;
return SearchResultItem(
id: id,
displayName: nameOrDefault,
icon: ResultIconPB(
ty: hasIcon
? ResultIconTypePB.valueOf(icon.ty.value)
: ResultIconTypePB.Icon,
value: hasIcon ? icon.value : '${layout.value}',
),
content: '',
);
}
}
extension StringSplitExtension on String {
List<String> splitIncludeSeparator(String separator) {
final splits =
split(RegExp(RegExp.escape(separator), caseSensitive: false));
final List<String> contents = [];
int charIndex = 0;
final seperatorLength = separator.length;
for (int i = 0; i < splits.length; i++) {
contents.add(splits[i]);
charIndex += splits[i].length;
if (i != splits.length - 1) {
contents.add(substring(charIndex, charIndex + seperatorLength));
charIndex += seperatorLength;
}
}
return contents;
}
}
@@ -0,0 +1,121 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/presentation/home/errors/workspace_failed_screen.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.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 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'mobile_search_ask_ai_entrance.dart';
import 'mobile_search_result.dart';
import 'mobile_search_textfield.dart';
class MobileSearchScreen extends StatelessWidget {
const MobileSearchScreen({
super.key,
});
static const routeName = '/search';
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
FolderEventGetCurrentWorkspaceSetting().send(),
getIt<AuthService>().getUser(),
]),
builder: (context, snapshots) {
if (!snapshots.hasData) {
return const Center(child: CircularProgressIndicator.adaptive());
}
final latest = snapshots.data?[0].fold(
(latest) {
return latest as WorkspaceLatestPB?;
},
(error) => null,
);
final userProfile = snapshots.data?[1].fold(
(userProfilePB) {
return userProfilePB as UserProfilePB?;
},
(error) => null,
);
// In the unlikely case either of the above is null, eg.
// when a workspace is already open this can happen.
if (latest == null || userProfile == null) {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Scaffold(
body: SafeArea(
bottom: false,
child: Provider.value(
value: userProfile,
child: MobileSearchPage(
userProfile: userProfile,
workspaceLatestPB: latest,
),
),
),
);
},
);
}
}
class MobileSearchPage extends StatelessWidget {
const MobileSearchPage({
super.key,
required this.userProfile,
required this.workspaceLatestPB,
});
final UserProfilePB userProfile;
final WorkspaceLatestPB workspaceLatestPB;
bool get enableShowAISearch =>
userProfile.workspaceAuthType == AuthTypePB.Server;
@override
Widget build(BuildContext context) {
return BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) => context
.read<CommandPaletteBloc>()
.add(CommandPaletteEvent.searchChanged(search: value)),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(child: SafeArea(child: MobileSearchResult())),
],
),
);
},
);
}
}
@@ -0,0 +1,189 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'mobile_search_cell.dart';
class MobileSearchResult extends StatelessWidget {
const MobileSearchResult({super.key});
@override
Widget build(BuildContext context) {
final state = context.read<CommandPaletteBloc>().state;
final query = (state.query ?? '').trim();
if (query.isEmpty) {
return const MobileSearchRecentList();
}
return MobileSearchResultList();
}
}
class MobileSearchRecentList extends StatelessWidget {
const MobileSearchRecentList({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocProvider(
create: (context) =>
RecentViewsBloc()..add(const RecentViewsEvent.initial()),
child: BlocBuilder<RecentViewsBloc, RecentViewsState>(
builder: (context, state) {
final List<ViewPB> recentViews =
state.views.map((e) => e.item).toSet().toList();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
Text(
LocaleKeys.sideBar_recent.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
),
const VSpace(4),
Column(
children: List.generate(recentViews.length, (index) {
final view = recentViews[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _goToView(context, view),
child: MobileSearchResultCell(
item: view.toSearchResultItem(),
),
);
}),
),
],
),
);
},
),
);
}
}
class MobileSearchResultList extends StatefulWidget {
const MobileSearchResultList({super.key});
@override
State<MobileSearchResultList> createState() => _MobileSearchResultListState();
}
class _MobileSearchResultListState extends State<MobileSearchResultList> {
late final SearchResultListBloc bloc;
@override
void initState() {
super.initState();
bloc = SearchResultListBloc();
}
@override
void dispose() {
bloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = context.read<CommandPaletteBloc>().state,
theme = AppFlowyTheme.of(context);
final isSearching = state.searching,
items = state.combinedResponseItems.values.toList(),
hasData = items.isNotEmpty;
if (isSearching && !hasData) {
return Center(child: CircularProgressIndicator.adaptive());
} else if (!hasData) {
return buildNoResult(state.query ?? '');
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
Text(
LocaleKeys.search_bestMatch.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
),
const VSpace(4),
Column(
children: List.generate(items.length, (index) {
final item = items[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
final view = await ViewBackendService.getView(item.id)
.fold((s) => s, (s) => null);
if (view != null && context.mounted) {
await _goToView(context, view);
}
},
child: MobileSearchResultCell(
item: item,
query: state.query,
),
);
}),
),
],
),
);
}
Widget buildNoResult(String query) {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.secondary;
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 140,
child: Column(
children: [
const VSpace(48),
FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
const VSpace(12),
Text(
LocaleKeys.search_noResultForSearching.tr(args: [query]),
style: theme.textStyle.body.enhanced(color: textColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
LocaleKeys.search_noResultForSearchingHint.tr(),
style: theme.textStyle.caption.standard(color: textColor),
),
],
),
),
);
}
}
Future<void> _goToView(BuildContext context, ViewPB view) async {
await context.pushView(
view,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
);
}
@@ -0,0 +1,168 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class MobileSearchTextfield extends StatefulWidget {
const MobileSearchTextfield({
super.key,
this.onChanged,
required this.hintText,
required this.query,
});
final String hintText;
final String query;
final ValueChanged<String>? onChanged;
@override
State<MobileSearchTextfield> createState() => _MobileSearchTextfieldState();
}
class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
late final TextEditingController controller;
late final FocusNode focusNode;
final ValueNotifier<bool> hasFocusValueNotifier = ValueNotifier(true);
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.query);
focusNode = FocusNode();
focusNode.addListener(() {
if (!mounted) return;
hasFocusValueNotifier.value = focusNode.hasFocus;
});
controller.addListener(() {
if (!mounted) return;
widget.onChanged?.call(controller.text);
});
bottomNavigationBarItemType.addListener(onBackOrLeave);
focusNode.requestFocus();
}
@override
void dispose() {
controller.dispose();
focusNode.dispose();
hasFocusValueNotifier.dispose();
bottomNavigationBarItemType.removeListener(onBackOrLeave);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
height: 40,
child: ValueListenableBuilder(
valueListenable: controller,
builder: (context, _, __) {
final hasText = controller.text.isNotEmpty;
return Row(
children: [
Expanded(
child: TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: true,
focusNode: focusNode,
textAlign: TextAlign.left,
controller: controller,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
color: theme.textColorScheme.primary,
),
decoration: buildInputDecoration(context),
),
),
ValueListenableBuilder(
valueListenable: hasFocusValueNotifier,
builder: (context, hasFocus, __) {
if (!hasFocus || !hasText) return SizedBox.shrink();
return GestureDetector(
onTap: () => focusNode.unfocus(),
child: Padding(
padding: EdgeInsets.only(left: 8),
child: Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.action),
),
),
);
},
),
],
);
},
),
);
}
InputDecoration buildInputDecoration(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final showCancelIcon = controller.text.isNotEmpty;
final border = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(color: theme.borderColorScheme.greyTertiary),
);
final enableBorder = border.copyWith(
borderSide: BorderSide(color: theme.borderColorScheme.themeThick),
);
final hintStyle = TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
color: theme.textColorScheme.tertiary,
);
return InputDecoration(
hintText: widget.hintText,
hintStyle: hintStyle,
contentPadding: const EdgeInsets.fromLTRB(8, 10, 8, 10),
isDense: true,
border: border,
enabledBorder: border,
focusedBorder: enableBorder,
prefixIconConstraints: BoxConstraints.loose(Size(34, 40)),
prefixIcon: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 4, 10),
child: FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(20),
),
),
suffixIconConstraints:
showCancelIcon ? BoxConstraints.loose(Size(34, 40)) : null,
suffixIcon: showCancelIcon
? GestureDetector(
onTap: () {
controller.clear();
},
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
child: FlowySvg(
FlowySvgs.clear_s,
blendMode: null,
color: theme.iconColorScheme.tertiary,
size: Size.square(20),
),
),
)
: null,
);
}
void onBackOrLeave() {
final label = bottomNavigationBarItemType.value;
if (label == BottomNavigationBarItemType.search.label) {
focusNode.requestFocus();
} else {
controller.clear();
}
}
}
@@ -0,0 +1,55 @@
import 'package:appflowy/startup/startup.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'view_ancestor_cache.dart';
part 'mobile_view_ancestors.freezed.dart';
class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
ViewAncestorBloc(String viewId) : super(ViewAncestorState.initial(viewId)) {
_cache = getIt<ViewAncestorCache>();
_dispatch();
}
late final ViewAncestorCache _cache;
void _dispatch() {
on<ViewAncestorEvent>(
(event, emit) async {
await event.map(
initial: (e) async {
final ancester = await _cache.getAncestor(
state.viewId,
onRefresh: (ancestor) {
if (!emit.isDone) {
emit(state.copyWith(ancestor: ancestor, isLoading: false));
}
},
);
emit(state.copyWith(ancestor: ancester, isLoading: false));
},
);
},
);
add(const ViewAncestorEvent.initial());
}
}
@freezed
class ViewAncestorEvent with _$ViewAncestorEvent {
const factory ViewAncestorEvent.initial() = Initial;
}
@freezed
class ViewAncestorState with _$ViewAncestorState {
const factory ViewAncestorState({
required ViewAncestor ancestor,
required String viewId,
@Default(true) bool isLoading,
}) = _ViewAncestorState;
factory ViewAncestorState.initial(String viewId) => ViewAncestorState(
viewId: viewId,
ancestor: ViewAncestor.empty(),
);
}
@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/material.dart';
class ViewAncestorCache {
ViewAncestorCache();
final Map<String, ViewAncestor> _ancestors = {};
Future<ViewAncestor> getAncestor(
String viewId, {
ValueChanged<ViewAncestor>? onRefresh,
}) async {
final cachedAncestor = _ancestors[viewId];
if (cachedAncestor != null) {
unawaited(_getAncestor(viewId, onRefresh: onRefresh));
return cachedAncestor;
}
return _getAncestor(viewId);
}
Future<ViewAncestor> _getAncestor(
String viewId, {
ValueChanged<ViewAncestor>? onRefresh,
}) async {
final List<ViewPB> ancestors =
await ViewBackendService.getViewAncestors(viewId).fold(
(s) => s.items
.where((e) => e.parentViewId.isNotEmpty && e.id != viewId)
.toList(),
(f) => [],
);
final newAncestors = ViewAncestor(
ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(),
);
_ancestors[viewId] = newAncestors;
onRefresh?.call(newAncestors);
return newAncestors;
}
}
class ViewAncestor {
const ViewAncestor({required this.ancestors});
const ViewAncestor.empty() : ancestors = const [];
final List<ViewParent> ancestors;
}
class ViewParent {
ViewParent({required this.id, required this.name});
final String id;
final String name;
static ViewParent fromViewPB(ViewPB view) =>
ViewParent(id: view.id, name: view.nameOrDefault);
}
@@ -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,
);
}
}
}
}
@@ -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(),
],
),
);
}
@@ -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(
@@ -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();
},
),
@@ -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)),
);
},
);
@@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
);
}
@@ -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.workspaceAuthType == 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),
],
);
},
),
@@ -197,11 +197,10 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
// only show the result dialog when the action is WorkspaceMemberActionType.add
if (actionType == WorkspaceMemberActionType.add) {
if (actionType == WorkspaceMemberActionType.addByEmail) {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@@ -218,18 +217,16 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
);
},
);
} else if (actionType == WorkspaceMemberActionType.invite) {
} else if (actionType == WorkspaceMemberActionType.inviteByEmail) {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@@ -247,18 +244,16 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
);
},
);
} else if (actionType == WorkspaceMemberActionType.remove) {
} else if (actionType == WorkspaceMemberActionType.removeByEmail) {
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,15 +276,15 @@ 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>()
.add(WorkspaceMemberEvent.inviteWorkspaceMember(email));
.add(WorkspaceMemberEvent.inviteWorkspaceMemberByEmail(email));
// clear the email field after inviting
emailController.clear();
}
@@ -178,7 +178,7 @@ class _MemberItem extends StatelessWidget {
showBottomBorder: false,
onTap: () {
workspaceMemberBloc.add(
WorkspaceMemberEvent.removeWorkspaceMember(
WorkspaceMemberEvent.removeWorkspaceMemberByEmail(
member.email,
),
);
@@ -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;
}
}
@@ -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
Loaded 100 of 896 files, more files were not shown because too many files have changed in this diff. Show more