Compare commits

...
Author SHA1 Message Date
Nathan 89647ae683 chore: logs for windows 2025-03-31 00:15:59 +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
Nathan 954aa48f52 chore: bump lai commit 2025-03-19 14:17:17 +08:00
Richard Shiue 461ac91b32 fix: continue writing empty text case (#7574) 2025-03-19 14:13:20 +08:00
Nathan.fooo 8a9cc278ec Merge pull request #7573 from AppFlowy-IO/rename_plugin
chore: rename the local ai plugin
2025-03-19 11:29:23 +08:00
Morn 9db87944f2 fix: toolbar tooltip message is incorrect on Windows (#7572)
* fix: some toolbar text display error

* chore: change string id to enum string id
2025-03-19 11:23:07 +08:00
Nathan 9230981e54 chore: remove test 2025-03-19 10:59:11 +08:00
Nathan 72b13dd941 chore: adjust UI 2025-03-19 10:48:30 +08:00
FakhriAzzouz e6b0c8ff05 chore: update Arabic translations (#7571)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-03-19 10:45:57 +08:00
Nathan f0d967f0e4 chore: rename the local ai plugin 2025-03-19 10:23:38 +08:00
Morn 85b9aab015 chore: update CHANGELOG.md (#7569) 2025-03-18 18:46:07 +08:00
Morn 69571f668c fix: some launch review issues (#7566)
* chore: adjust some toolbar text

* chore: change the icons in turn-into menu

* chore: change the icon in color menu

* fix: keep selection after doing some changes from toolbar

* fix: color menu displaying error

* fix: wrong filter logic in toolbar suggestion menu

* fix: some launch review issues

* fix: test errors
2025-03-18 18:45:31 +08:00
Lucas a89dd87c16 fix: optimize cover title position offset calculation (#7568)
* fix: optimize cover title position offset calculation

* feat: ingore shift+enter in callout/quote and fallback to system behavior
2025-03-18 17:53:21 +08:00
Richard Shiue 22b03eee29 chore: implement ai writer history (#7523)
* chore: implement ai writer history

* chore: pass hitosyr
2025-03-18 17:14:20 +08:00
Nathan.fooo e3ea3fcdfa Merge pull request #7570 from AppFlowy-IO/update_local_ai_translation
chore: update local ai translation
2025-03-18 16:43:39 +08:00
Nathan ccfbde9a92 chore: update local ai translation 2025-03-18 16:42:29 +08:00
Nathan.fooo 7358860bfc Merge pull request #7567 from AppFlowy-IO/fix_release_crash_2
chore: update commit
2025-03-18 16:07:25 +08:00
Nathan 17b355197c chore: update commit 2025-03-18 15:54:16 +08:00
Nathan.fooo aa7e50cc6c Merge pull request #7565 from AppFlowy-IO/update_translation
chore: update translation
2025-03-18 13:00:33 +08:00
Nathan 69ce105806 chore: update translation 2025-03-18 12:54:45 +08:00
Lucas cafdfcca51 feat: add download button in file block (#7562) 2025-03-18 11:31:18 +08:00
a8c5c9c34e chore(i18n): add and fine-tune ko-KR translations (#7548)
* feat(translations): reset hard Korean translation

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

* i18n(ko-KR): Add new translations and fine-tune existing ones

Co-authored-by: NavyStack <navystack@askfront.com>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>

---------

Co-authored-by: fvoci <karl@hwan.dev>
Co-authored-by: FVOCI <150913557+fvoci@users.noreply.github.com>
2025-03-18 10:30:51 +08:00
Morn 6dd83675fc fix: toolbar launch review issues (#7532)
* fix: some launch review issues

* fix: some launch review issues

* fix: color picker position error

* fix: redesign the dropdown arrow and padding

* feat: implement toolbar button state

* fix: keep custom color not changed

* feat: revamp color icon in toolbar

* fix: correct toolbar position & add animation for toolbar

* fix: ajust toolbar animation parameters

* chore: adjust some UI values

* fix: keep selection after turn into

* fix: hover color on toolbar is wrong in dark mode

* fix: toolbar icon color in dark mode
2025-03-17 18:03:23 +08:00
Lucas b65fad6214 chore: update template link url (#7557) 2025-03-17 16:57:02 +08:00
Lucas 884046ba3f fix: first added cover might be invisible (#7555) 2025-03-17 15:41:54 +08:00
Lucas 6d327adb83 fix: quote block flashes in ai chat page when generating answer (#7553) 2025-03-17 13:03:47 +08:00
Richard Shiue eddb623fba fix: message hover action flashing (#7552) 2025-03-17 11:02:42 +08:00
Lucas 2ea8e831cd fix: the workflow of switching to Local in app (#7540)
* fix: unable to redo undo when the selection is null

* fix: the workflow of switching to Local in app

* fix: text color doesn't work in table cell

* fix: test
2025-03-17 09:36:46 +08:00
Nathan.fooo 270c981051 Merge pull request #7549 from AppFlowy-IO/fix_internal_stream
chore: bump client api
2025-03-16 20:07:48 +08:00
Nathan 7971566159 chore: bump client api 2025-03-16 19:45:51 +08:00
Nathan.fooo 9fbf2d5ef6 Merge pull request #7547 from AppFlowy-IO/related_question_height
chore: set releated question height
2025-03-16 13:34:34 +08:00
Nathan 21bf2968a9 chore: set releated question height 2025-03-16 12:14:11 +08:00
Nathan.fooo 117950c922 Merge pull request #7546 from AppFlowy-IO/local_embed
chore: local ai embed file
2025-03-16 12:12:45 +08:00
Nathan b4c56f7998 Merge branch 'main' into local_embed 2025-03-16 10:23:21 +08:00
Nathan af0c802486 chore: local ai embed file 2025-03-16 10:22:58 +08:00
Lucas 33d518f383 chore: update unsplash client (#7543) 2025-03-14 21:52:44 +08:00
Nathan.fooo 1f9fe89f87 Merge pull request #7542 from AppFlowy-IO/replace_guide_url
chore: replace guide url
2025-03-14 21:52:06 +08:00
Nathan 5fef4f1d49 chore: replace guide url 2025-03-14 21:51:21 +08:00
Nathan.fooo d6446872ee Merge pull request #7541 from AppFlowy-IO/disable_chat_with_file
chore: disable chat with file
2025-03-14 20:55:34 +08:00
Nathan 5b5feb2515 chore: disable chat with file 2025-03-14 20:53:14 +08:00
Nathan.fooo b1bca1b55b Merge pull request #7538 from AppFlowy-IO/update_local_ai_log
Update local ai log
2025-03-14 18:32:36 +08:00
Nathan 4e1a70c7ac chore: update local ai crate 2025-03-14 17:15:12 +08:00
Nathan 54f25e4b91 Merge branch 'main' into update_local_ai_log 2025-03-14 14:29:35 +08:00
Richard Shiue aa176f2c12 fix(mobile): image formats not shown (#7537) 2025-03-14 14:14:40 +08:00
Nathan 58895620c1 chore: update local ai logs 2025-03-14 13:23:41 +08:00
Nathan e10aade895 chore: update local ai client 2025-03-14 11:36:30 +08:00
Richard Shiue 1fdd7c343b chore: use tooltip instead of multi line for related questions (#7533) 2025-03-14 11:31:05 +08:00
Richard Shiue e36b08cd14 chore: add tooltip for disabled select messages (#7536) 2025-03-14 11:10:52 +08:00
Richard Shiue 6ac9ad1cac fix: continue writing edge case (#7535) 2025-03-14 11:10:40 +08:00
Richard Shiue 36bf90e81b fix: stop generating in ai chat and writer (#7534)
* fix: stop generating shortcuts not working edge cases

* chore: add tooltip
2025-03-14 11:10:29 +08:00
Nathan 22b9acf386 chore: send local ai state 2025-03-13 19:51:42 +08:00
Nathan.fooo e4e75acdac Merge pull request #7527 from AppFlowy-IO/fix_windows_terminal
Fix windows terminal
2025-03-13 19:32:07 +08:00
Nathan 7996736592 chore: enable linux local ai 2025-03-13 17:27:55 +08:00
Nathan 3ad8f624cf chore: enable linux local ai 2025-03-13 17:03:49 +08:00
Nathan 0657aeb07d chore: set local ai default value 2025-03-13 17:02:32 +08:00
Nathan 723971e423 chore: fix editable 2025-03-13 16:44:41 +08:00
Nathan 86b67a1b65 chore: fix flashing window 2025-03-13 15:49:44 +08:00
Nathan d94b4daa70 chore: fix flashing window 2025-03-13 15:29:58 +08:00
Lucas.Xu ad62e85b3a Merge branch 'main' into fix_windows_terminal 2025-03-13 15:09:26 +08:00
Lucas 133eec8163 chore: update dsb_pub.pem on Windows (#7528) 2025-03-13 14:30:09 +08:00
Nathan 81bac5950c chore: try to fix windows flashing window issue 2025-03-13 14:01:41 +08:00
Lucas 651046ab68 fix: unable to paste iframe in editor (#7525) 2025-03-13 13:58:24 +08:00
Morn 9bd13ac29e fix: the slash menu position sometimes is wrong (#7492) 2025-03-13 13:58:06 +08:00
Morn c7d3d612ae fix: emoji picker position error (#7497)
* fix: click an emoji should close the menu when using /emoji to insert an emoji into a doc

* fix: emoji picker position error

* fix: document emoji icon is clipped on Android
2025-03-13 13:57:58 +08:00
Morn 69dd2ab20f feat: revamp toolbar UI (#7506)
* feat: revamp toolbar UI

* fix: integration test issues

* feat: add suggestions for toolbar

* feat: support dark mode

* chore: update editor dependency

* feat: add testing for suggestions

* chore: update editor dependency
2025-03-13 13:51:03 +08:00
Nathan f0b8b00461 chore: force restart plugin 2025-03-13 13:45:01 +08:00
Nathan.fooo 2b8aaf1d46 Merge pull request #7522 from AppFlowy-IO/response_format_local_ai
chore: support response format
2025-03-13 13:08:52 +08:00
Nathan ee69283a23 chore: support response format 2025-03-13 10:53:20 +08:00
Lucas caaf5f7986 chore: remove deprecated pem files (#7521) 2025-03-13 10:27:11 +08:00
Richard Shiue 392964ffd2 chore: disable add messages to page when chat is empty (#7518) 2025-03-13 09:50:46 +08:00
Lucas 1f76412790 fix: nested list issue in quote/callout block (#7513) 2025-03-13 09:26:56 +08:00
Richard Shiue 555254e8fe chore: add ai writer keyboard shortcuts (#7516) 2025-03-12 21:46:06 +08:00
Nathan.fooo 3aa55f83b1 chore: Merge pull request #7515 from AppFlowy-IO/local_ai_opti
chore: disable input when local ai is initializing
2025-03-12 21:20:48 +08:00
Nathan d15a8a88a6 chore: disable input when local ai is initializing 2025-03-12 20:29:03 +08:00
Nathan 1f7ab9d22d chore: fix ios compile 2025-03-12 15:08:37 +08:00
Morn 44945b2912 fix: some slash menu issues (#7501)
* fix: slash menu unexpectedly overflows the screen

* fix: the style of no result doesn’t match the design

* fix: unexpected flashing effect on 2nd level menu item

* fix: can not back to last level through backspace
2025-03-12 14:10:41 +08:00
Lucas 8d50caa86e fix: remove layout builder in quote block (#7508)
* fix: remove layout builder in quote block

* fix: quote block selection color

* fix: quote block and callout block background color issue

* fix: background color in callout block

* fix: quote block layout on mobile
2025-03-12 13:54:24 +08:00
Richard Shiue b59eba76a6 fix: hide continue writing when document is empty (#7498)
* fix: hide continue writing when document is empty

* chore: code clean up and add documentation
2025-03-12 13:54:00 +08:00
Lucas 070cde9ecb chore: bump version 0.8.7 (#7512) 2025-03-12 13:34:59 +08:00
FakhriAzzouz 1e81e4c68f chore: update arabic translation
Arabic Translation Updates
2025-03-12 13:32:24 +08:00
Nathan.fooo 402ca7d765 Merge pull request #7511 from AppFlowy-IO/fix_ios_build
chore: fix ios build
2025-03-12 11:50:13 +08:00
Khor Shu Heng d0ca7f311c Merge pull request #7509 from khorshuheng/fix-recursion-trash-view
fix: prevent segfault due to infinite recursion in trash view
2025-03-12 11:42:40 +08:00
Nathan e553627ee5 chore: fix ios build 2025-03-12 11:16:51 +08:00
khorshuheng cbdac71025 fix: prevent segfault due to infinite recursion in trash view 2025-03-12 10:21:21 +08:00
Nathan.fooo 6f35ae9857 Merge pull request #7504 from richardshiue/chore/ollama-hide-formats
chore: hide predefined format section when using local ai
2025-03-12 09:57:44 +08:00
Nathan 20d64cc7ae chore: lint 2025-03-12 00:09:20 +08:00
Nathan 654e18aacf chore: remove local ai 2025-03-11 23:19:20 +08:00
Richard Shiue 75dd5c1d93 chore: regenerate 2025-03-11 22:18:44 +08:00
Richard Shiue f8f9c3404a chore: show text options still 2025-03-11 22:05:30 +08:00
Richard Shiue 01e5817b24 chore: code cleanup 2025-03-11 17:49:51 +08:00
Richard Shiue 96608bd005 chore: hide predefined format section when using local ai 2025-03-11 17:06:55 +08:00
Nathan.fooo 57a5b38509 Merge pull request #7488 from AppFlowy-IO/ollama
feat: support Ollama
2025-03-11 13:53:35 +08:00
Nathan 83c53188e3 chore: clippy 2025-03-11 13:22:59 +08:00
Nathan 6ba7f93f69 chore: find plugin load 2025-03-11 13:14:47 +08:00
Nathan 702a486cce chore: find windows exe 2025-03-11 12:55:19 +08:00
Richard Shiue eb0ed1ad86 fix: don't allow selection of text in related questions or loading (#7500) 2025-03-11 11:38:20 +08:00
Lucas e264b3a5b8 Merge pull request #7490 from richardshiue/fix/callout-icon-in-markdown
fix: don't include icon while exporting callout to md
2025-03-11 10:38:46 +08:00
Richard Shiue 667d15c627 fix: ai writer gestures (#7499) 2025-03-11 10:31:08 +08:00
Nathan bd06e1d559 chore: clippy 2025-03-11 09:32:20 +08:00
Lucas 459aca5291 Merge pull request #7496 from LucasXu0/ai_writer_position_improvement
feat: ensure the ai writer block visible when generating result
2025-03-11 09:31:24 +08:00
Nathan e7cd90b6ab chore: update commit 2025-03-11 09:14:11 +08:00
Nathan 940db70447 chore: fix build 2025-03-11 00:27:55 +08:00
Nathan 59139ff323 chore: catch panic 2025-03-10 23:40:28 +08:00
Nathan 22fed1bfbc chore: load from env command 2025-03-10 22:48:09 +08:00
Lucas.Xu 5e593bd36e chore: update appflowy editor version 2025-03-10 21:08:28 +08:00
Lucas.Xu ba1dfc6de4 feat: ensure the ai writer block visible when generating result 2025-03-10 19:18:06 +08:00
Lucas c81f87dcdc feat: make the columns block same width width the editor (#7493)
* feat: make the columns block same width width the editor

* chore: turn off column debug mode

* feat: add block selection container in outline block

* feat: use ratio instead of width in simple columns

* fix: document rules

* fix: turn off debug mode

* fix: update the existing columns block data
2025-03-10 18:13:15 +08:00
Nathan a8b55ca3f0 chore: update prompt 2025-03-10 15:56:09 +08:00
Richard Shiue 0cefaf633c fix: fix test 2025-03-10 14:56:01 +08:00
Richard Shiue ba4aebd005 chore: merge remote-tracking branch 'main' into this one 2025-03-10 13:36:08 +08:00
Lucas 7b32a92290 fix: callout block build error (#7491) 2025-03-10 13:04:29 +08:00
Richard Shiue 41b99209f1 fix: retain if is emoji 2025-03-10 12:10:59 +08:00
Morn c1612fe298 feat: add custom icons for callout (#7449) 2025-03-10 11:46:55 +08:00
Lucas e69a09d332 feat: support nested list in callout block and quote block (#7479)
* feat: support nested list in callout block

* chore: update pubspec.yml

* feat: add new quote block

* feat: support nested list in quote block

* feat: refacotr quote block

* feat: optimize quote block align

* feat: support nested list in quote block

* fix: icon and drag menu overlap

* chore: update appflowy editor version

* feat: support trailing action builder for plugin blocks

* chore: update appflowy editor version
2025-03-10 11:46:17 +08:00
Richard Shiue 4e0d9fdb0b fix: don't include icon while exporting callout to md 2025-03-10 11:08:22 +08:00
Nathan 8b2e769fca chore: ai setting ui 2025-03-10 10:24:55 +08:00
Nathan d29a90a472 chore: init ai plugin on separate thread 2025-03-10 08:54:32 +08:00
Nathan 2e4beb0652 chore: enable windows 2025-03-10 00:35:52 +08:00
Nathan addb041816 chore: update exe path 2025-03-10 00:25:19 +08:00
Nathan 4ff71b5dce chore: implement ollama 2025-03-09 23:32:42 +08:00
Richard Shiue a0ae62d6f5 fix: consider simple table in exclude table types (#7478) 2025-03-07 12:32:27 +08:00
Lucas ea18aa7551 chore: bump collab version 45239d2 (#7477) 2025-03-07 11:17:56 +08:00
Morn 68e7069e92 chore: bump version 0.8.6 & update changelog (#7473)
* chore: bump version 0.8.6

* chore: update changelog
2025-03-06 22:03:30 +08:00
Morn 556d929b67 fix: error caused by ScrollablePositionedList(#7460) (#7469)
* fix: error caused by ScrollablePositionedList

* chore: update appflowy_editor version
2025-03-06 18:45:27 +08:00
Lucas 7f3469a0f2 feat: use undo to revert the autotypograph (#7472) 2025-03-06 18:22:10 +08:00
Lucas a062c4aadb fix: bulleted list icon does not center in the columns (#7471) 2025-03-06 16:59:58 +08:00
Morn 3d3f81ad52 fix: complete the missing icons in the database (#7464)
* fix: complete the missing icons in the database

* fix: the toggle is slower than the actual change taken into effect
2025-03-06 16:04:54 +08:00
Lucas fc0fb0b3d3 fix: update locked page button background color (#7470) 2025-03-06 16:02:56 +08:00
Richard Shiue 884586f0af fix: ai writer issues (#7467)
* fix: disable ai writer in table

* fix: enable header row by default when converting from md

* chore: add title when continue writing

* chore: rewrite using predefined format

* fix: mouse & keyboard event still propagate

* chore: bump editor ref
2025-03-06 15:09:33 +08:00
Morn f8c18afbcf fix: improve the experience of using icon color picker (#7468) 2025-03-06 12:39:18 +08:00
Lucas 8046177d84 fix: simple columns issues (#7466)
* Revert "feat: use flutter_distrubutor to build linux and macos packages (#7392)"

This reverts commit 6dc45c9830.

* fix: linux link issue

* fix: outline doesn't work well in columns

* fix: cannot drag a block under a table that’s in the second column
2025-03-06 12:33:53 +08:00
Morn 8d8fc91391 fix: title position working incorrectly with document width setting (#7465) 2025-03-06 12:33:12 +08:00
Richard Shiue 796fda159e chore: bump client api (#7463) 2025-03-06 09:59:53 +08:00
Nathan.fooo 4b2389dafd chore: bump client api (#7455) 2025-03-05 10:23:28 +08:00
Richard ShiueandLucas.Xu 2dd7e5937f fix: incorrect popover position (#7452)
* fix: incorrect popover position

* fix: tests

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-03-04 20:20:26 +08:00
Lucas e9371029f3 chore: update changelog (#7451) 2025-03-04 17:17:57 +08:00
Richard Shiue bbec60ff02 fix(flutter_desktop): page name overflow in search (#7450) 2025-03-04 17:17:42 +08:00
Lucas 3bf4f080c5 feat: auto calculate the column width when resizing (#7448)
* feat: calculate the column width auto

* fix: ai writer table issue
2025-03-04 16:34:26 +08:00
Richard Shiue 637c043f5b fix: ai writer launch review 0.8.5 (#7445)
* fix: improve writing not working

* fix: show insert below and discard buttons even in overflow

* fix: incorrect predefined format initialization

* fix: generate image

* chore: multi-line related questions

* fix: add to undo history

* fix: disable keyboard service when using ai writer

* fix: disable drag nodes

* fix: strikethrough text after accepting

* fix: undo
2025-03-04 16:34:07 +08:00
MornandLucas 9eed993421 feat: refactor databse styles (#7405)
* feat: refactor databse styles

* feat: support compact mode for databse

* feat: support dynamic height for board

* fix: add reference icon for database view in document

* feat: support data sync for database node in document

* fix: add hover effect in compact mode switcher

* fix: title of document not align correctly with a large screen

* fix: some launch review issues

* fix: auto hide the Hidden Groups unless the user clicks it to show

* fix: testing error

* chore: update board version

* chore: update database menu buttons

* fix: some launch review issues

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2025-03-04 11:29:38 +08:00
Lucas aff720c1f1 fix: hide the improve writing button (#7443)
* fix: hide the improve writing button

* chore: update appflowy_editor version

* fix: simple column width

* Revert "fix: hide the improve writing button"

This reverts commit 815a28971c.
2025-03-04 11:29:24 +08:00
Lucas 655de30df5 fix: unable to delete the callout block when it's in the first line (#7442) 2025-03-03 16:40:41 +08:00
Richard Shiue fe6217bd82 feat: ai writer block (#7406)
* feat: ai writer block

* test: fix integration tests

* chore: add continue writing to slash menu

* chore: focus issues during insertion

* fix: explain button position

* fix: gesture detection

* fix: insert below

* fix: undo

* chore: improve writing toolbar item

* chore: pass predefined format when using quick commands

* fix: continue writing in an empty document or at the beginning of a document

* fix: don't allow selecting text not in content

* fix: related question not following predefined format
2025-03-03 13:35:51 +08:00
Morn eacd7b2503 fix: error display when showing SnackBar with dialog (#7440) 2025-03-03 13:15:50 +08:00
Lucas 2e17fb9dd3 fix: can't open the relation field in the linked database (#7441) 2025-03-03 13:14:29 +08:00
Morn 249543d64f fix: using [[ to create subpage with error text (#7434) 2025-03-03 11:22:40 +08:00
Lucas 8ebd490260 feat: support scrollable columns block (#7429)
* feat: support scrollable columns block

* fix: simple columns block issues on mobile

* feat: hide drag menu when resizing the columns block
2025-03-03 11:22:13 +08:00
Morn c0dfec8b34 fix: potential issues with displaying CircleAvatar (#7432) 2025-03-03 11:21:44 +08:00
Lucas 56a023c98a fix: the locked hint is not visible when there's a cover (#7439) 2025-03-03 11:20:58 +08:00
Lucas adcac881a7 fix: 0.8.5 launch review issues (#7430)
* chore: replace two columns with 2 columns

* fix: hide drag menu when the doc is locked

* feat: add placeholder when editing the paragraph

* fix: ingore tab shortcut in document title

* feat: forward the video block to link preview block
2025-03-03 09:57:36 +08:00
Lucas f73342d902 fix: auto updater should not block the launch process (#7427) 2025-02-28 15:18:21 +08:00
FakhriAzzouz 45b0233c21 chore: update Arabic translations (#7361)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-02-27 13:14:48 +08:00
Lucas db349519cf chore: bump version 0.8.5 (#7421) 2025-02-27 13:13:49 +08:00
Lucas c760a1b1fe feat: support columns block in editor on desktop (#7402)
* feat: support columns block in editor

* feat: upgrade simple columns block

* fix: build error

* feat: add column width resizer

* fix: drag visual border

* fix: drag button position issue

* feat: add rule to check if the column is empty

* fix: flutter analyze

* feat: add document rules to delete the columns if its children are empty

* feat: support adding image in columns block

* feat: integrate block actions in columns block

* feat: support dragging to create a columns block

* feat: drag a block into an existing columns block

* feat: add delete columns and delete column rules

* feat: dragging the block to the left side of another block to create a columns block

* feat: support 2-4 columns block in slash menu

* chore: disable debug flag in columns block

* chore: update pubspec.yaml

* chore: update translations and icons

* fix: cloud integration test

* fix: integration test
2025-02-27 13:08:49 +08:00
MornandLucas c5fa9039b4 fix: add shortcut to create Inline Math Equation (#7401)
* fix: add shortcut to create Math Equation(#7331)

* chore: update code

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

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2025-02-26 10:06:28 +08:00
Richard Shiue 7eaafc52ce fix: adjust other user message alignment (#7414) 2025-02-25 10:35:53 +08:00
Richard Shiue 63239893ab chore(flutter): move other user message to end (#7413) 2025-02-24 15:14:26 +08:00
Annie e9a1a1ced0 chore: Update README.md (#7411)
change appflowy.io to appflowy.com wherever possible
2025-02-23 13:46:13 +08:00
LucasandMorn 6dc45c9830 feat: use flutter_distrubutor to build linux and macos packages (#7392)
* feat: use flutter_distrubutor to build linux packages

* feat: verify deb on Linux

* chore: update rpm deps

* chore: update codesign files

* chore: update rpm make_config.yaml

* chore: update release.yml

* chore: update release.yml

* chore: update feed url

* chore: rename AppFlowy to appflowy

* chore: update CHANGELOG.md (#7397)

* chore: create release path if not exist

* feat: support appimage

* Revert "feat: support appimage"

This reverts commit cb7dcf725c.

* fix: cp deb/rpm error

* feat: support appimage

* chore: add linux build script

* feat: add macos build script

* feat: update linux scripts

* chore: update linux scripts

* chore: update relesae script

* chore: update macos build scripts

* chore: rename macOS package name

* chore: add keychain in release.yaml

* chore: update macos build steps in release.yaml

* chore: update macos script desc

* chore: remove sudo

* feat: support tar.xz package type

* feat: support tar.xz package type

* chore: add fuse

---------

Co-authored-by: Morn <agedchen@gmail.com>
2025-02-21 17:39:13 +08:00
Morn 58f7659d55 fix: avatar displays error (#7403) 2025-02-21 14:28:24 +08:00
527 changed files with 23605 additions and 9748 deletions

No files matched your search

+37
View File
@@ -1,4 +1,41 @@
# Release Notes
## Version 0.8.7 - 18/03/2025
### New Features
- Made local AI free and integrated with Ollama
- Supported nested lists within callout and quote blocks
- Revamped the document's floating toolbar and added Turn Into
- Enabled custom icons in callout blocks
### Bug Fixes
- Fixed occasional incorrect positioning of the slash menu
- Improved AI Chat and AI Writers with various bug fixes
- Adjusted the columns block to match the width of the editor
- Fixed a potential segfault caused by infinite recursion in the trash view
- Resolved an issue where the first added cover might be invisible
- Fixed adding cover images via Unsplash
## Version 0.8.6 - 06/03/2025
### Bug Fixes
- Fix the incorrect title positioning when adjusting the document width setting
- Enhance the user experience of the icon color picker for smoother interactions
- Add missing icons to the database to ensure completeness and consistency
- Resolve the issue with links not functioning correctly on Linux systems
- Improve the outline feature to work seamlessly within columns
- Center the bulleted list icon within columns for better visual alignment
- Enable dragging blocks under tables in the second column to enhance flexibility
- Disable the AI writer feature within tables to prevent conflicts and improve usability
- Automatically enable the header row when converting content from Markdown to ensure proper formatting
- Use the "Undo" function to revert the auto-formatting
## Version 0.8.5 - 04/03/2025
### New Features
- Columns in Documents: Arrange content side by side using drag-and-drop or the slash menu
- AI Writers: New AI assistants in documents with response formatting options (list, table, text with images, image-only), follow-up questions, contextual memory, and more
- Compact Mode for Databases: Enable compact mode for grid and kanban views (full-page and inline) to increase information density, displaying more data per screen
### Bug Fixes
- Fixed an issue where callout blocks couldn’t be deleted when appearing as the first line in a document
- Fixed a bug preventing the relation field in databases from opening
- Fixed an issue where links in documents were unclickable on Linux
## Version 0.8.4 - 18/02/2025
### New Features
- Switch AI mode on mobile
+12 -14
View File
@@ -1,6 +1,6 @@
<h1 align="center" style="border-bottom: none">
<b>
<a href="https://www.appflowy.io">AppFlowy.IO</a><br>
<a href="https://www.appflowy.com">AppFlowy</a><br>
</b>
⭐️ The Open Source Alternative To Notion ⭐️ <br>
</h1>
@@ -18,18 +18,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
</p>
<p align="center">
<a href="https://www.appflowy.io"><b>Website</b></a> •
<a href="https://www.appflowy.com"><b>Website</b></a> •
<a href="https://forum.appflowy.io/"><b>Forum</b></a> •
<a href="https://discord.gg/9Q2xaN37tV"><b>Discord</b></a> •
<a href="https://www.reddit.com/r/AppFlowy"><b>Reddit</b></a> •
<a href="https://twitter.com/appflowy"><b>Twitter</b></a>
</p>
<p align="center"><img src="https://appflowy.io/_next/static/media/tasks.796c753e.png" alt="AppFlowy Kanban Board for To-dos" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/Grid.9e30484b.png" alt="AppFlowy Databases for Tasks and Projects" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/sites.a8d5b2b9.png" alt="AppFlowy Sites for Beautiful documentation" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/ai.e1460982.png" alt="AppFlowy AI" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/template.9ea13c3b.png" alt="AppFlowy Templates" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/tasks.796c753e.png" alt="AppFlowy Kanban Board for To-dos" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/Grid.9e30484b.png" alt="AppFlowy Databases for Tasks and Projects" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/sites.a8d5b2b9.png" alt="AppFlowy Sites for Beautiful documentation" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/ai.e1460982.png" alt="AppFlowy AI" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/template.9ea13c3b.png" alt="AppFlowy Templates" /></p>
<br></br>
<p align="center" >
@@ -48,7 +48,7 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is
not supported
- [Self-hosting AppFlowy](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)
- [Self-hosting AppFlowy](https://appflowy.com/docs/self-host-appflowy-overview)
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
## Built With
@@ -78,7 +78,7 @@ report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labe
## **Releases**
Please see the [changelog](https://www.appflowy.io/whatsnew) for more details about a given release.
Please see the [changelog](https://appflowy.com/what-is-new) for more details about a given release.
## Contributing
@@ -89,9 +89,7 @@ for details.
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly
easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains
the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with
us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
the community, **Congratulations!** You are now an official contributor to AppFlowy.
## Translations 🌎🗺
@@ -152,8 +150,8 @@ more information.
## Acknowledgments
Special thanks to these amazing projects which help power AppFlowy.IO:
Special thanks to these amazing projects which help power AppFlowy:
- [cargo-make](https://github.com/sagiegurari/cargo-make)
- [contrib.rocks](https://contrib.rocks)
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
+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.4"
APPFLOWY_VERSION = "0.8.8"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
-26
View File
@@ -1,26 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEXAIBADCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0Y
ruaTrrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7t
J8mG4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy
7xyw+sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL
7iTVKiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqH
Opf5b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdp
qm4ZQRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFy
JiJWYWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ
5EhGG4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAh
Lswu6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPh
AsVA6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxC
xMTpq1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIA
Pxbd0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+V
uix/4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/
8WrbK13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqT
QJg7hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19
tKcOs6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQd
bsCzAxp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzf
J4v4uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6T
jcfVWthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NCl
WgZnixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3w
m7NB+fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcT
ilaNC9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkEHgIcfy0+ZHp+4MBcWSDv
uzWeM8QmNvbP+owM+H4F7A==
-----END PRIVATE KEY-----
@@ -23,24 +23,24 @@ void main() {
final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s);
// Is expanded by default
expect(collapseFinder, findsOneWidget);
expect(expandFinder, findsNothing);
// Collapse hidden groups
await tester.tap(collapseFinder);
await tester.pumpAndSettle();
// Is collapsed
expect(collapseFinder, findsNothing);
expect(expandFinder, findsOneWidget);
// Expand hidden groups
// Collapse hidden groups
await tester.tap(expandFinder);
await tester.pumpAndSettle();
// Is expanded
// Is collapsed
expect(collapseFinder, findsOneWidget);
expect(expandFinder, findsNothing);
// Expand hidden groups
await tester.tap(collapseFinder);
await tester.pumpAndSettle();
// Is expanded
expect(collapseFinder, findsNothing);
expect(expandFinder, findsOneWidget);
});
testWidgets('hide first group, and show it again', (tester) async {
@@ -48,6 +48,9 @@ void main() {
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board);
final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s);
await tester.tapButton(expandFinder);
// Tap the options of the first group
final optionsFinder = find
.descendant(
@@ -33,7 +33,7 @@ void main() {
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
expect(find.byType(AiWriterBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
@@ -41,7 +41,7 @@ void main() {
await tester.openPage(pageName);
// expect the ai writer block is not in the document
expect(find.byType(AIWriterBlockComponent), findsNothing);
expect(find.byType(AiWriterBlockComponent), findsNothing);
});
});
}
@@ -57,7 +57,7 @@ void main() {
// move the checkbox to the child of the block at path [9]
await tester.editor.dragBlock(
[10],
const Offset(80, -30),
const Offset(120, -20),
);
// wait for the move animation to complete
@@ -27,8 +27,9 @@ void main() {
await tester.pumpAndSettle();
// click the align center
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
await tester
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_center_m);
// expect to see the align center
final editorState = tester.editor.getCurrentEditorState();
@@ -36,13 +37,15 @@ void main() {
expect(first.attributes[blockComponentAlign], 'center');
// click the align right
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_center_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
await tester
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_right_m);
expect(first.attributes[blockComponentAlign], 'right');
// click the align left
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_right_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_align_left_s);
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_alignment_m);
await tester
.tapButtonWithFlowySvgData(FlowySvgs.toolbar_text_align_left_m);
expect(first.attributes[blockComponentAlign], 'left');
});
@@ -75,7 +78,7 @@ void main() {
[
LogicalKeyboardKey.control,
LogicalKeyboardKey.shift,
LogicalKeyboardKey.keyE,
LogicalKeyboardKey.keyC,
],
tester: tester,
withKeyUp: true,
@@ -76,13 +76,12 @@ void main() {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_bulletedList.tr():
LocaleKeys.editor_bulletedListShortForm.tr():
BulletedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_numberedList.tr():
LocaleKeys.editor_numberedListShortForm.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
@@ -117,13 +116,12 @@ void main() {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_bulletedList.tr():
LocaleKeys.editor_bulletedListShortForm.tr():
BulletedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_numberedList.tr():
LocaleKeys.editor_numberedListShortForm.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.editor_checkbox.tr(): TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
@@ -1,5 +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';
@@ -8,24 +22,33 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
Future<void> selectText(WidgetTester tester, String text) async {
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
);
}
Future<void> prepareForToolbar(WidgetTester tester, String text) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(text);
await selectText(tester, text);
}
group('document toolbar:', () {
testWidgets('font family', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
const text = 'font family';
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
);
await prepareForToolbar(tester, 'font family');
// tap more options button
await tester.tapButtonWithFlowySvgData(FlowySvgs.toolbar_more_m);
// tap the font family button
final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey);
await tester.tapButton(fontFamilyButton);
@@ -46,5 +69,262 @@ void main() {
abel,
);
});
testWidgets('heading 1~3', (tester) async {
const text = 'heading';
await prepareForToolbar(tester, text);
Future<void> testChangeHeading(
FlowySvgData svg,
String title,
int level,
) async {
/// tap suggestions item
final suggestionsButton = find.byKey(kSuggestionsItemKey);
await tester.tapButton(suggestionsButton);
/// tap item
await tester.ensureVisible(find.byFlowySvg(svg));
await tester.tapButton(find.byFlowySvg(svg));
/// check the type of node is [HeadingBlockKeys.type]
await selectText(tester, text);
final editorState = tester.editor.getCurrentEditorState();
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!,
nodeLevel = node.attributes[HeadingBlockKeys.level]!;
expect(node.type, HeadingBlockKeys.type);
expect(nodeLevel, level);
/// show toolbar again
await selectText(tester, text);
/// the text of suggestions item should be changed
expect(
find.descendant(of: suggestionsButton, matching: find.text(title)),
findsOneWidget,
);
}
await testChangeHeading(
FlowySvgs.type_h1_m,
LocaleKeys.document_toolbar_h1.tr(),
1,
);
await testChangeHeading(
FlowySvgs.type_h2_m,
LocaleKeys.document_toolbar_h2.tr(),
2,
);
await testChangeHeading(
FlowySvgs.type_h3_m,
LocaleKeys.document_toolbar_h3.tr(),
3,
);
});
testWidgets('toggle 1~3', (tester) async {
const text = 'toggle';
await prepareForToolbar(tester, text);
Future<void> testChangeToggle(
FlowySvgData svg,
String title,
int? level,
) async {
/// tap suggestions item
final suggestionsButton = find.byKey(kSuggestionsItemKey);
await tester.tapButton(suggestionsButton);
/// tap item
await tester.ensureVisible(find.byFlowySvg(svg));
await tester.tapButton(find.byFlowySvg(svg));
/// check the type of node is [HeadingBlockKeys.type]
await selectText(tester, text);
final editorState = tester.editor.getCurrentEditorState();
final selection = editorState.selection!;
final node = editorState.getNodeAtPath(selection.start.path)!,
nodeLevel = node.attributes[ToggleListBlockKeys.level];
expect(node.type, ToggleListBlockKeys.type);
expect(nodeLevel, level);
/// show toolbar again
await selectText(tester, text);
/// the text of suggestions item should be changed
expect(
find.descendant(of: suggestionsButton, matching: find.text(title)),
findsOneWidget,
);
}
await testChangeToggle(
FlowySvgs.type_toggle_list_m,
LocaleKeys.editor_toggleListShortForm.tr(),
null,
);
await testChangeToggle(
FlowySvgs.type_toggle_h1_m,
LocaleKeys.editor_toggleHeading1ShortForm.tr(),
1,
);
await testChangeToggle(
FlowySvgs.type_toggle_h2_m,
LocaleKeys.editor_toggleHeading2ShortForm.tr(),
2,
);
await testChangeToggle(
FlowySvgs.type_toggle_h3_m,
LocaleKeys.editor_toggleHeading3ShortForm.tr(),
3,
);
});
});
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 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);
});
});
}
@@ -1,9 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.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';
@@ -32,9 +34,15 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// tap the more options button
final moreOptionButton = find.findFlowyTooltip(
LocaleKeys.document_toolbar_moreOptions.tr(),
);
await tester.tapButton(moreOptionButton);
// tap the inline math equation button
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
final inlineMathEquationButton = find.text(
LocaleKeys.document_toolbar_equation.tr(),
);
await tester.tapButton(inlineMathEquationButton);
@@ -77,10 +85,15 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// tap the inline math equation button
var inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
// tap the more options button
final moreOptionButton = find.findFlowyTooltip(
LocaleKeys.document_toolbar_moreOptions.tr(),
);
await tester.tapButton(moreOptionButton);
// tap the inline math equation button
final inlineMathEquationButton =
find.byFlowySvg(FlowySvgs.type_formula_m);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
@@ -92,17 +105,7 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: 1),
);
// expect to the see the inline math equation button is highlighted
inlineMathEquationButton = find.descendant(
of: find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
),
matching: find.byType(SVGIconItemWidget),
);
expect(
tester.widget<SVGIconItemWidget>(inlineMathEquationButton).isHighlight,
isTrue,
);
await tester.tapButton(moreOptionButton);
// cancel the format
await tester.tapButton(inlineMathEquationButton);
@@ -133,10 +136,15 @@ void main() {
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// tap the inline math equation button
final inlineMathEquationButton = find.findFlowyTooltip(
LocaleKeys.document_plugins_createInlineMathEquation.tr(),
// tap the more options button
final moreOptionButton = find.findFlowyTooltip(
LocaleKeys.document_toolbar_moreOptions.tr(),
);
await tester.tapButton(moreOptionButton);
// tap the inline math equation button
final inlineMathEquationButton =
find.byFlowySvg(FlowySvgs.type_formula_m);
await tester.tapButton(inlineMathEquationButton);
// expect to see the math equation block
@@ -163,5 +171,55 @@ void main() {
lessThan(5),
);
});
testWidgets('insert inline math equation by shortcut', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'insert inline math equation by shortcut',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
const formula = 'E = MC ^ 2';
await tester.ime.insertText(formula);
await tester.editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// mock key event
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyE,
isShiftPressed: true,
isControlPressed: true,
);
// expect to see the math equation block
final inlineMathEquation = find.byType(InlineMathEquation);
expect(inlineMathEquation, findsOneWidget);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
final inlineText = find.textContaining(text, findRichText: true);
expect(inlineText, findsOneWidget);
// the text should be in the same line with the math equation
final inlineMathEquationPosition = tester.getRect(inlineMathEquation);
final textPosition = tester.getRect(inlineText);
// allow 5px difference
expect(
(textPosition.top - inlineMathEquationPosition.top).abs(),
lessThan(5),
);
expect(
(textPosition.bottom - inlineMathEquationPosition.bottom).abs(),
lessThan(5),
);
});
});
}
@@ -48,7 +48,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 80,
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@@ -146,7 +146,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 80,
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -85,16 +86,10 @@ void main() {
),
);
await tester.tapButton(find.byType(HeadingPopup));
await tester.pumpAndSettle();
expect(
find.byType(HeadingButton),
findsNWidgets(3),
);
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_text_format_m));
// tap the H1 button
await tester.tapButton(find.byType(HeadingButton).at(0));
await tester.tapButton(find.byFlowySvg(FlowySvgs.type_h1_m).at(0));
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
@@ -1,8 +1,10 @@
import 'dart:io';
import 'package:appflowy/plugins/emoji/emoji_handler.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -39,4 +41,114 @@ void main() {
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
});
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(':');
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 for `smiling eyes`, IME is not working, use keyboard input
final searchText = [
LogicalKeyboardKey.keyS,
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);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
/// input space
await tester.simulateKeyEvent(LogicalKeyboardKey.space);
/// emoji list is dismissed
expect(emojiHandler, findsNothing);
});
});
}
@@ -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();
@@ -37,5 +37,20 @@ void main() {
await tester.tap(actionWidgets.last);
expect(find.byType(MentionPageBlock), findsOneWidget);
});
testWidgets('create subpage with at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile(title);
await tester.editor.tapLineOfEditorAt(0);
const subpageName = 'Subpage';
await tester.ime.insertText('[[$subpageName');
await tester.pumpAndSettle();
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tapButton(actionWidgets.first);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0]);
assert(firstNode != null);
expect(firstNode!.delta?.toPlainText().contains('['), false);
});
});
}
@@ -617,7 +617,7 @@ extension CommonOperations on WidgetTester {
);
final distanceY = getCenter(to).dy - getCenter(from).dx;
await drag(from, Offset(0, distanceY));
await pumpAndSettle();
await pumpAndSettle(const Duration(seconds: 1));
}
// tap the button with [FlowySvgData]
@@ -1,17 +1,9 @@
import 'dart:io';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart';
import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart';
@@ -27,10 +19,11 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
@@ -44,6 +37,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart';
@@ -76,6 +70,8 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio
import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
@@ -90,6 +86,9 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
// Non-exported member of the table_calendar library
@@ -1572,7 +1571,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
of: textField,
matching: find.byWidgetPredicate(
(widget) =>
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_m,
widget is FlowySvg && widget.svg == FlowySvgs.close_filled_s,
),
),
);
@@ -1,81 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/ai/service/error.dart';
import 'package:appflowy/ai/service/openai_client.dart';
import 'package:appflowy/ai/service/text_completion.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
class MyMockClient extends Mock implements http.Client {
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final requestType = request.method;
final requestUri = request.url;
if (requestType == 'POST' &&
requestUri == OpenAIRequestType.textCompletion.uri) {
final responseHeaders = <String, String>{
'content-type': 'text/event-stream',
};
final responseBody = Stream.fromIterable([
utf8.encode(
'{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}',
),
utf8.encode('\n'),
utf8.encode('[DONE]'),
]);
// Return a mocked response with the expected data
return http.StreamedResponse(responseBody, 200, headers: responseHeaders);
}
// Return an error response for any other request
return http.StreamedResponse(const Stream.empty(), 404);
}
}
class MockOpenAIRepository extends HttpOpenAIRepository {
MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient());
@override
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
}) async {
final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
final response = await client.send(request);
String previousSyntax = '';
if (response.statusCode == 200) {
await for (final chunk in response.stream
.transform(const Utf8Decoder())
.transform(const LineSplitter())) {
await onStart();
final data = chunk.trim().split('data: ');
if (data[0] != '[DONE]') {
final response = TextCompletionResponse.fromJson(
json.decode(data[0]),
);
if (response.choices.isNotEmpty) {
final text = response.choices.first.text;
if (text == previousSyntax && text == '\n') {
continue;
}
await onProcess(response);
previousSyntax = response.choices.first.text;
}
} else {
await onEnd();
}
}
}
}
}
+23 -23
View File
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
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
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
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
+8 -1
View File
@@ -1,6 +1,12 @@
export 'service/ai_entities.dart';
export 'service/ai_prompt_input_bloc.dart';
export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'service/ai_model_state_notifier.dart';
export 'service/select_model_bloc.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_input_text_field.dart';
export 'widgets/prompt_input/desktop_prompt_text_field.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';
@@ -9,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/predefined_format_buttons.dart';
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
export 'widgets/prompt_input/select_sources_menu.dart';
export 'widgets/prompt_input/select_model_menu.dart';
export 'widgets/prompt_input/send_button.dart';
@@ -1,34 +0,0 @@
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'error.dart';
import 'text_completion.dart';
abstract class AIRepository {
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
});
Future<void> streamCompletion({
String? objectId,
required String text,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
});
Future<FlowyResult<List<String>, AIError>> generateImage({
required String prompt,
int n = 1,
});
}
@@ -0,0 +1,107 @@
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:equatable/equatable.dart';
class AIStreamEventPrefix {
static const data = 'data:';
static const error = 'error:';
static const metadata = 'metadata:';
static const start = 'start:';
static const finish = 'finish:';
static const comment = 'comment:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
static const localAIDisabled = 'LOCAL_AI_DISABLED';
}
enum AiType {
cloud,
local;
bool get isCloud => this == cloud;
bool get isLocal => this == local;
}
class PredefinedFormat extends Equatable {
const PredefinedFormat({
required this.imageFormat,
required this.textFormat,
});
final ImageFormat imageFormat;
final TextFormat? textFormat;
PredefinedFormatPB toPB() {
return PredefinedFormatPB(
imageFormat: switch (imageFormat) {
ImageFormat.text => ResponseImageFormatPB.TextOnly,
ImageFormat.image => ResponseImageFormatPB.ImageOnly,
ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
},
textFormat: switch (textFormat) {
TextFormat.paragraph => ResponseTextFormatPB.Paragraph,
TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
TextFormat.table => ResponseTextFormatPB.Table,
_ => null,
},
);
}
@override
List<Object?> get props => [imageFormat, textFormat];
}
enum ImageFormat {
text,
image,
textAndImage;
bool get hasText => this == text || this == textAndImage;
FlowySvgData get icon {
return switch (this) {
ImageFormat.text => FlowySvgs.ai_text_s,
ImageFormat.image => FlowySvgs.ai_image_s,
ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
};
}
String get i18n {
return switch (this) {
ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
ImageFormat.textAndImage =>
LocaleKeys.chat_changeFormat_textAndImage.tr(),
};
}
}
enum TextFormat {
paragraph,
bulletList,
numberedList,
table;
FlowySvgData get icon {
return switch (this) {
TextFormat.paragraph => FlowySvgs.ai_paragraph_s,
TextFormat.bulletList => FlowySvgs.ai_list_s,
TextFormat.numberedList => FlowySvgs.ai_number_list_s,
TextFormat.table => FlowySvgs.ai_table_s,
};
}
String get i18n {
return switch (this) {
TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(),
TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
};
}
}
@@ -0,0 +1,181 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:protobuf/protobuf.dart';
import 'package:universal_platform/universal_platform.dart';
typedef OnModelStateChangedCallback = void Function(AiType, bool, String);
typedef OnAvailableModelsChangedCallback = void Function(
List<AIModelPB>,
AIModelPB?,
);
class AIModelStateNotifier {
AIModelStateNotifier({required this.objectId})
: _localAIListener =
UniversalPlatform.isDesktop ? LocalAIStateListener() : null,
_aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) {
_startListening();
_init();
}
final String objectId;
final LocalAIStateListener? _localAIListener;
final AIModelSwitchListener _aiModelSwitchListener;
LocalAIPB? _localAIState;
AvailableModelsPB? _availableModels;
// callbacks
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
final List<OnAvailableModelsChangedCallback>
_availableModelsChangedCallbacks = [];
void _startListening() {
if (UniversalPlatform.isDesktop) {
_localAIListener?.start(
stateCallback: (state) async {
_localAIState = state;
_notifyStateChanged();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadAvailableModels();
_notifyAvailableModelsChanged();
}
},
);
}
_aiModelSwitchListener.start(
onUpdateSelectedModel: (model) async {
final updatedModels = _availableModels?.deepCopy()
?..selectedModel = model;
_availableModels = updatedModels;
_notifyAvailableModelsChanged();
if (model.isLocal && UniversalPlatform.isDesktop) {
await _loadLocalAiState();
}
_notifyStateChanged();
},
);
}
void _init() async {
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
_notifyStateChanged();
_notifyAvailableModelsChanged();
}
void addListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
}) {
if (onStateChanged != null) {
_stateChangedCallbacks.add(onStateChanged);
}
if (onAvailableModelsChanged != null) {
_availableModelsChangedCallbacks.add(onAvailableModelsChanged);
}
}
void removeListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
}) {
if (onStateChanged != null) {
_stateChangedCallbacks.remove(onStateChanged);
}
if (onAvailableModelsChanged != null) {
_availableModelsChangedCallbacks.remove(onAvailableModelsChanged);
}
}
Future<void> dispose() async {
_stateChangedCallbacks.clear();
_availableModelsChangedCallbacks.clear();
await _localAIListener?.stop();
await _aiModelSwitchListener.stop();
}
(AiType, String, bool) getState() {
if (UniversalPlatform.isMobile) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
final availableModels = _availableModels;
final localAiState = _localAIState;
if (availableModels == null) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
if (localAiState == null) {
Log.warn("Cannot get local AI state");
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
if (!availableModels.selectedModel.isLocal) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
final editable = localAiState.state == RunningStatePB.Running;
final hintText = editable
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
return (AiType.local, hintText, editable);
}
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
final availableModels = _availableModels;
if (availableModels == null) {
return ([], null);
}
return (availableModels.models, availableModels.selectedModel);
}
void _notifyAvailableModelsChanged() {
final (models, selectedModel) = getAvailableModels();
for (final callback in _availableModelsChangedCallbacks) {
callback(models, selectedModel);
}
}
void _notifyStateChanged() {
final (type, hintText, isEditable) = getState();
for (final callback in _stateChangedCallbacks) {
callback(type, isEditable, hintText);
}
}
Future<void> _loadAvailableModels() {
final payload = AvailableModelsQueryPB(source: objectId);
return AIEventGetAvailableModels(payload).send().fold(
(models) => _availableModels = models,
(err) => Log.error("Failed to get available models: $err"),
);
}
Future<void> _loadLocalAiState() {
return AIEventGetLocalAIState().send().fold(
(localAIState) => _localAIState = localAIState,
(error) => Log.error("Failed to get local AI state: $error"),
);
}
}
extension AiModelExtension on AIModelPB {
bool get isDefault {
return name == "Auto";
}
String get i18n {
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
}
}
@@ -1,31 +1,31 @@
import 'dart:async';
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:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'ai_entities.dart';
part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc()
: _listener = LocalLLMListener(),
super(AIPromptInputState.initial()) {
AIPromptInputBloc({
required String objectId,
required PredefinedFormat? predefinedFormat,
}) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch();
_startListening();
_init();
}
final LocalLLMListener _listener;
final AIModelStateNotifier aiModelStateNotifier;
@override
Future<void> close() async {
await _listener.stop();
await aiModelStateNotifier.dispose();
return super.close();
}
@@ -33,38 +33,37 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
updateChatState: (LocalAIChatPB chatState) {
// Only user enable chat with file and the plugin is already running
final supportChatWithFile = chatState.fileEnabled &&
chatState.pluginState.state == RunningStatePB.Running;
final aiType = chatState.pluginState.state == RunningStatePB.Running
? AIType.localAI
: AIType.appflowyAI;
updateAIState: (aiType, editable, hintText) {
emit(
state.copyWith(
aiType: aiType,
supportChatWithFile: supportChatWithFile,
chatState: chatState,
editable: editable,
hintText: hintText,
),
);
},
updatePluginState: (LocalAIPluginStatePB chatState) {
final fileEnabled = state.chatState?.fileEnabled ?? false;
final supportChatWithFile =
fileEnabled && chatState.state == RunningStatePB.Running;
final aiType = chatState.state == RunningStatePB.Running
? AIType.localAI
: AIType.appflowyAI;
toggleShowPredefinedFormat: () {
final showPredefinedFormats = !state.showPredefinedFormats;
final predefinedFormat =
showPredefinedFormats && state.predefinedFormat == null
? PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.paragraph,
)
: null;
emit(
state.copyWith(
supportChatWithFile: supportChatWithFile,
aiType: aiType,
showPredefinedFormats: showPredefinedFormats,
predefinedFormat: predefinedFormat,
),
);
},
updatePredefinedFormat: (format) {
if (!state.showPredefinedFormats) {
return;
}
emit(state.copyWith(predefinedFormat: format));
},
attachFile: (filePath, fileName) {
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
@@ -105,29 +104,16 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
}
void _startListening() {
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(AIPromptInputEvent.updateChatState(chatState));
}
aiModelStateNotifier.addListener(
onStateChanged: (aiType, editable, hintText) {
add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
},
);
}
void _init() {
AIEventGetLocalAIChatState().send().fold(
(chatState) {
if (!isClosed) {
add(AIPromptInputEvent.updateChatState(chatState));
}
},
Log.error,
);
final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
}
Map<String, dynamic> consumeMetadata() {
@@ -146,12 +132,17 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateChatState(
LocalAIChatPB chatState,
) = _UpdateChatState;
const factory AIPromptInputEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory AIPromptInputEvent.updateAIState(
AiType aiType,
bool editable,
String hintText,
) = _UpdateAIState;
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
_ToggleShowPredefinedFormat;
const factory AIPromptInputEvent.updatePredefinedFormat(
PredefinedFormat format,
) = _UpdatePredefinedFormat;
const factory AIPromptInputEvent.attachFile(
String filePath,
String fileName,
@@ -165,25 +156,25 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
@freezed
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required AIType aiType,
required AiType aiType,
required bool supportChatWithFile,
required LocalAIChatPB? chatState,
required bool showPredefinedFormats,
required PredefinedFormat? predefinedFormat,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
required bool editable,
required String hintText,
}) = _AIPromptInputState;
factory AIPromptInputState.initial() => const AIPromptInputState(
aiType: AIType.appflowyAI,
factory AIPromptInputState.initial(PredefinedFormat? format) =>
AIPromptInputState(
aiType: AiType.cloud,
supportChatWithFile: false,
chatState: null,
showPredefinedFormats: format != null,
predefinedFormat: format,
attachedFiles: [],
mentionedPages: [],
editable: true,
hintText: '',
);
}
enum AIType {
appflowyAI,
localAI;
bool get isLocalAI => this == localAI;
}
@@ -3,193 +3,202 @@ import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:appflowy/shared/list_extension.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:fixnum/fixnum.dart' as fixnum;
import 'ai_client.dart';
import 'ai_entities.dart';
import 'error.dart';
import 'text_completion.dart';
enum AskAIAction {
summarize,
fixSpelling,
improveWriting,
makeItLonger;
enum LocalAIStreamingState {
notReady,
disabled,
}
String get toInstruction => switch (this) {
summarize => 'Tl;dr',
fixSpelling => 'Correct this to standard English:',
improveWriting => 'Rewrite this in your own words:',
makeItLonger => 'Make this text longer:',
};
String prompt(String input) => switch (this) {
summarize => '$input\n\n$toInstruction',
_ => "$toInstruction\n\n$input",
};
static AskAIAction from(int index) => switch (index) {
0 => summarize,
1 => fixSpelling,
2 => improveWriting,
3 => makeItLonger,
_ => fixSpelling
};
String get name => switch (this) {
summarize => LocaleKeys.document_plugins_smartEditSummarize.tr(),
fixSpelling => LocaleKeys.document_plugins_smartEditFixSpelling.tr(),
improveWriting =>
LocaleKeys.document_plugins_smartEditImproveWriting.tr(),
makeItLonger => LocaleKeys.document_plugins_smartEditMakeLonger.tr(),
};
abstract class AIRepository {
Future<void> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
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,
});
}
class AppFlowyAIService implements AIRepository {
@override
Future<FlowyResult<List<String>, AIError>> generateImage({
required String prompt,
int n = 1,
}) {
throw UnimplementedError();
}
@override
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
}) {
throw UnimplementedError();
}
@override
Future<CompletionStream> streamCompletion({
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
List<String> sourceIds = const [],
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 = CompletionStream(
onStart,
onProcess,
onEnd,
onError,
final stream = AppFlowyCompletionStream(
onStart: onStart,
processMessage: processMessage,
processAssistMessage: processAssistMessage,
processError: onError,
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
onEnd: onEnd,
);
final List<String> ragIds = [];
if (objectId != null) {
ragIds.add(objectId);
}
final records = history.map((record) => record.toPB()).toList();
final payload = CompleteTextPB(
text: text,
completionType: completionType,
format: format?.toPB(),
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? "",
ragIds: ragIds,
objectId: objectId ?? '',
ragIds: [
if (objectId != null) objectId,
...sourceIds,
].unique(),
history: records,
);
// ignore: unawaited_futures
AIEventCompleteText(payload).send();
return stream;
}
}
CompletionTypePB completionTypeFromInt(AskAIAction action) {
switch (action) {
case AskAIAction.summarize:
return CompletionTypePB.MakeShorter;
case AskAIAction.fixSpelling:
return CompletionTypePB.SpellingAndGrammar;
case AskAIAction.improveWriting:
return CompletionTypePB.ImproveWriting;
case AskAIAction.makeItLonger:
return CompletionTypePB.MakeLonger;
}
}
class CompletionStream {
CompletionStream(
Future<void> Function() onStart,
Future<void> Function(String text) onProcess,
Future<void> Function() onEnd,
void Function(AIError error) onError,
) {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
if (event == "AI_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.sideBar_aiResponseLimit.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
}
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.sideBar_aiImageResponseLimit.tr(),
code: AIErrorCode.aiImageResponseLimitExceeded,
),
);
}
if (event.startsWith("AI_MAX_REQUIRED:")) {
final msg = event.substring(16);
onError(
AIError(
message: msg,
),
);
}
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)));
}
return AIEventCompleteText(payload).send().fold(
(task) => (task.taskId, stream),
(error) {
Log.error(error);
return null;
},
);
}
}
abstract class CompletionStream {
CompletionStream({
required this.onStart,
required this.processMessage,
required this.processAssistMessage,
required this.processError,
required this.onLocalAIStreamingStateChange,
required this.onEnd,
});
final Future<void> Function() onStart;
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;
}
class AppFlowyCompletionStream extends CompletionStream {
AppFlowyCompletionStream({
required super.onStart,
required super.processMessage,
required super.processAssistMessage,
required super.processError,
required super.onEnd,
required super.onLocalAIStreamingStateChange,
}) {
_startListening();
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
int get nativePort => _port.sendPort.nativePort;
void _startListening() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
await _handleEvent(event);
},
);
}
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
StreamSubscription<String> listen(
void Function(String event)? onData,
) {
return _controller.stream.listen(onData);
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');
}
}
}
@@ -7,7 +7,7 @@ part 'error.g.dart';
class AIError with _$AIError {
const factory AIError({
required String message,
@Default(AIErrorCode.other) AIErrorCode code,
required AIErrorCode code,
}) = _AIError;
factory AIError.fromJson(Map<String, Object?> json) =>
@@ -1,173 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:http/http.dart' as http;
import 'ai_client.dart';
import 'error.dart';
import 'text_completion.dart';
enum OpenAIRequestType {
textCompletion,
textEdit,
imageGenerations;
Uri get uri {
switch (this) {
case OpenAIRequestType.textCompletion:
return Uri.parse('https://api.openai.com/v1/completions');
case OpenAIRequestType.textEdit:
return Uri.parse('https://api.openai.com/v1/chat/completions');
case OpenAIRequestType.imageGenerations:
return Uri.parse('https://api.openai.com/v1/images/generations');
}
}
}
class HttpOpenAIRepository implements AIRepository {
const HttpOpenAIRepository({
required this.client,
required this.apiKey,
});
final http.Client client;
final String apiKey;
Map<String, String> get headers => {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
};
@override
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
}) async {
final parameters = {
'model': 'gpt-3.5-turbo-instruct',
'prompt': prompt,
'suffix': suffix,
'max_tokens': maxTokens,
'temperature': temperature,
'stream': true,
};
final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
request.headers.addAll(headers);
request.body = jsonEncode(parameters);
final response = await client.send(request);
// NEED TO REFACTOR.
// WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE?
// AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE?
int syntax = 0;
var previousSyntax = '';
if (response.statusCode == 200) {
await for (final chunk in response.stream
.transform(const Utf8Decoder())
.transform(const LineSplitter())) {
syntax += 1;
if (!useAction) {
if (syntax == 3) {
await onStart();
continue;
} else if (syntax < 3) {
continue;
}
} else {
if (syntax == 2) {
await onStart();
continue;
} else if (syntax < 2) {
continue;
}
}
final data = chunk.trim().split('data: ');
if (data.length > 1) {
if (data[1] != '[DONE]') {
final response = TextCompletionResponse.fromJson(
json.decode(data[1]),
);
if (response.choices.isNotEmpty) {
final text = response.choices.first.text;
if (text == previousSyntax && text == '\n') {
continue;
}
await onProcess(response);
previousSyntax = response.choices.first.text;
}
} else {
await onEnd();
}
}
}
} else {
final body = await response.stream.bytesToString();
onError(
AIError.fromJson(json.decode(body)['error']),
);
}
return;
}
@override
Future<FlowyResult<List<String>, AIError>> generateImage({
required String prompt,
int n = 1,
}) async {
final parameters = {
'prompt': prompt,
'n': n,
'size': '512x512',
};
try {
final response = await client.post(
OpenAIRequestType.imageGenerations.uri,
headers: headers,
body: json.encode(parameters),
);
if (response.statusCode == 200) {
final data = json.decode(
utf8.decode(response.bodyBytes),
)['data'] as List;
final urls = data
.map((e) => e.values)
.expand((e) => e)
.map((e) => e.toString())
.toList();
return FlowyResult.success(urls);
} else {
return FlowyResult.failure(
AIError.fromJson(json.decode(response.body)['error']),
);
}
} catch (error) {
return FlowyResult.failure(AIError(message: error.toString()));
}
}
@override
Future<void> streamCompletion({
String? objectId,
required String text,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
}) {
throw UnimplementedError();
}
}
@@ -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,
);
}
}
@@ -1,27 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'text_completion.freezed.dart';
part 'text_completion.g.dart';
@freezed
class TextCompletionChoice with _$TextCompletionChoice {
factory TextCompletionChoice({
required String text,
required int index,
// ignore: invalid_annotation_target
@JsonKey(name: 'finish_reason') String? finishReason,
}) = _TextCompletionChoice;
factory TextCompletionChoice.fromJson(Map<String, Object?> json) =>
_$TextCompletionChoiceFromJson(json);
}
@freezed
class TextCompletionResponse with _$TextCompletionResponse {
const factory TextCompletionResponse({
required List<TextCompletionChoice> choices,
}) = _TextCompletionResponse;
factory TextCompletionResponse.fromJson(Map<String, Object?> json) =>
_$TextCompletionResponseFromJson(json);
}
@@ -16,8 +16,7 @@ class AILoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
return Padding(
padding: const EdgeInsets.only(top: 8.0),
return SelectionContainer.disabled(
child: SizedBox(
height: 20,
child: SeparatedRow(
@@ -1,53 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:extended_text_field/extended_text_field.dart';
import 'package:flutter/material.dart';
import 'mentioned_page_text_span.dart';
class PromptInputTextField extends StatelessWidget {
const PromptInputTextField({
super.key,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
required this.contentPadding,
this.hintText = "",
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
final EdgeInsetsGeometry contentPadding;
final String hintText;
@override
Widget build(BuildContext context) {
return ExtendedTextField(
controller: textController,
focusNode: textFieldFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: contentPadding,
hintText: hintText,
hintStyle: AIChatUILayout.inputHintTextStyle(context),
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
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,
),
),
);
}
}
@@ -1,53 +1,52 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.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: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 '../layout_define.dart';
class DesktopChatInput extends StatefulWidget {
const DesktopChatInput({
class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
required this.chatId,
required this.isStreaming,
required this.textController,
required this.onStopStreaming,
required this.onSubmitted,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
this.extraBottomActionButton,
});
final String chatId;
final bool isStreaming;
final TextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
final Widget? extraBottomActionButton;
@override
State<DesktopChatInput> createState() => _DesktopChatInputState();
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
}
class _DesktopChatInputState extends State<DesktopChatInput> {
class _DesktopPromptInputState extends State<DesktopPromptInput> {
final textFieldKey = GlobalKey();
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
bool showPredefinedFormatSection = true;
PredefinedFormat predefinedFormat = const PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.bulletList,
);
late SendButtonState sendButtonState;
bool isComposing = false;
@@ -55,16 +54,19 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChanged);
// refresh border color on focus change and hide menu when lost focus
focusNode.addListener(
() => setState(() {
if (!focusNode.hasFocus) {
cancelMentionPage();
}
}),
);
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();
@@ -82,7 +84,7 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
@override
void dispose() {
focusNode.dispose();
textController.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
super.dispose();
}
@@ -107,20 +109,12 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
overlayChildBuilder: (context) {
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
textController: textController,
textController: widget.textController,
onPageSelected: handlePageSelected,
);
},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
),
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
),
decoration: decoration(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -132,7 +126,6 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
),
child: TextFieldTapRegion(
child: PromptInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
@@ -140,53 +133,64 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
),
),
const VSpace(4.0),
Stack(
children: [
Container(
constraints: getTextFieldConstraints(),
child: inputTextField(),
),
if (showPredefinedFormatSection)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
child: Padding(
padding:
const EdgeInsetsDirectional.only(start: 8.0),
child: ChangeFormatBar(
predefinedFormat: predefinedFormat,
spacing: 4.0,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Stack(
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: 8.0,
),
child: ChangeFormatBar(
showImageFormats: state.aiType.isCloud,
predefinedFormat: state.predefinedFormat,
spacing: 4.0,
onSelectPredefinedFormat: (format) =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
.updatePredefinedFormat(format),
),
),
),
),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormats:
state.showPredefinedFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
.toggleShowPredefinedFormat(),
),
onStartMention: startMentionPageFromButton,
sendButtonState: sendButtonState,
onSendPressed: handleSend,
onStopStreaming: widget.onStopStreaming,
selectedSourcesNotifier:
widget.selectedSourcesNotifier,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
extraBottomActionButton:
widget.extraBottomActionButton,
),
),
),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
textController: textController,
overlayController: overlayController,
focusNode: focusNode,
showPredefinedFormats: showPredefinedFormatSection,
predefinedFormat: predefinedFormat,
onTogglePredefinedFormatSection: () {
setState(() {
showPredefinedFormatSection =
!showPredefinedFormatSection;
});
},
sendButtonState: sendButtonState,
onSendPressed: handleSend,
onStopStreaming: widget.onStopStreaming,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
),
),
),
],
],
);
},
),
],
),
@@ -196,6 +200,40 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
);
}
BoxDecoration decoration(BuildContext context) {
if (widget.hideDecoration) {
return BoxDecoration();
}
return BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
),
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
);
}
void startMentionPageFromButton() {
if (overlayController.isShowing) {
return;
}
if (!focusNode.hasFocus) {
focusNode.requestFocus();
}
widget.textController.text += '@';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context
.read<ChatInputControlCubit>()
.startSearching(widget.textController.value);
overlayController.show();
}
});
}
void cancelMentionPage() {
if (overlayController.isShowing) {
inputControlCubit.reset();
@@ -206,7 +244,7 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
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;
@@ -218,9 +256,9 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
widget.textController.text.trim(),
);
textController.clear();
widget.textController.clear();
if (trimmedText.isEmpty) {
return;
}
@@ -228,9 +266,13 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
widget.onSubmitted(
trimmedText,
showPredefinedFormatSection ? predefinedFormat : null,
showPredefinedFormats ? predefinedFormat : null,
metadata,
);
}
@@ -239,17 +281,17 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
setState(() {
// update whether send button is clickable
updateSendButtonState();
isComposing = !textController.value.composing.isCollapsed;
isComposing = !widget.textController.value.composing.isCollapsed;
});
if (isComposing) {
return;
}
// handle text and selection changes ONLY when mentioning a page
// disable mention
return;
// handle text and selection changes ONLY when mentioning a page
// ignore: dead_code
if (!overlayController.isShowing ||
inputControlCubit.filterStartPosition == -1) {
@@ -257,6 +299,7 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
}
// handle cases where mention a page is cancelled
final textController = widget.textController;
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
@@ -303,22 +346,27 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
}
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,
@@ -330,18 +378,6 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
overlayController.hide();
}
BoxConstraints getTextFieldConstraints() {
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarSendButtonSize +
DesktopAIChatSizes.inputActionBarMargin.vertical;
double maxHeight = 300;
if (showPredefinedFormatSection) {
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
}
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
}
Widget inputTextField() {
return Shortcuts(
shortcuts: buildShortcuts(),
@@ -351,17 +387,27 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return PromptInputTextField(
Widget textField = PromptInputTextField(
key: textFieldKey,
editable: state.editable,
cubit: inputControlCubit,
textController: textController,
textController: widget.textController,
textFieldFocusNode: focusNode,
contentPadding: calculateContentPadding(),
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
hintText: state.hintText,
);
if (!state.editable) {
textField = FlowyTooltip(
message: LocaleKeys
.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr(),
child: textField,
);
}
return textField;
},
),
),
@@ -369,8 +415,20 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
);
}
EdgeInsetsGeometry calculateContentPadding() {
final top = showPredefinedFormatSection
BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) {
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarSendButtonSize +
DesktopAIChatSizes.inputActionBarMargin.vertical;
double maxHeight = 300;
if (showPredefinedFormats) {
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
}
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
}
EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) {
final top = showPredefinedFormats
? DesktopAIPromptSizes.predefinedFormatButtonHeight
: 0.0;
final bottom = DesktopAIPromptSizes.actionBarSendButtonSize +
@@ -451,30 +509,87 @@ class _FocusNextItemIntent extends Intent {
const _FocusNextItemIntent();
}
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
class PromptInputTextField extends StatelessWidget {
const PromptInputTextField({
super.key,
required this.editable,
required this.cubit,
required this.textController,
required this.overlayController,
required this.focusNode,
required this.sendButtonState,
required this.predefinedFormat,
required this.onTogglePredefinedFormatSection,
required this.showPredefinedFormats,
required this.onSendPressed,
required this.onStopStreaming,
required this.onUpdateSelectedSources,
required this.textFieldFocusNode,
required this.contentPadding,
this.hintText = "",
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final OverlayPortalController overlayController;
final FocusNode focusNode;
final FocusNode textFieldFocusNode;
final EdgeInsetsGeometry contentPadding;
final bool editable;
final String hintText;
@override
Widget build(BuildContext context) {
return ExtendedTextField(
controller: textController,
focusNode: textFieldFocusNode,
readOnly: !editable,
enabled: editable,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: contentPadding,
hintText: hintText,
hintStyle: inputHintTextStyle(context),
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
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,
),
),
);
}
TextStyle? inputHintTextStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).isLightMode
? const Color(0xFFBDC2C8)
: const Color(0xFF3C3E51),
);
}
}
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.sendButtonState,
required this.showPredefinedFormats,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
required this.onStopStreaming,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.extraBottomActionButton,
});
final bool showPredefinedFormats;
final PredefinedFormat predefinedFormat;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final Widget? extraBottomActionButton;
@override
Widget build(BuildContext context) {
@@ -483,18 +598,25 @@ class _PromptBottomActions extends StatelessWidget {
margin: DesktopAIChatSizes.inputActionBarMargin,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
if (state.chatState == null) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _sendButton(),
);
}
return Row(
children: [
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
),
const Spacer(),
if (state.aiType == AIType.appflowyAI) ...[
_selectSourcesButton(context),
if (state.aiType.isCloud) ...[
_selectSourcesButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (extraBottomActionButton != null) ...[
extraBottomActionButton!,
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
@@ -520,14 +642,14 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
predefinedFormat: predefinedFormat,
onTap: onTogglePredefinedFormatSection,
);
}
Widget _selectSourcesButton(BuildContext context) {
Widget _selectSourcesButton() {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
selectedSourcesNotifier: selectedSourcesNotifier,
);
}
@@ -535,21 +657,7 @@ class _PromptBottomActions extends StatelessWidget {
// return PromptInputMentionButton(
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
// buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
// onTap: () {
// if (overlayController.isShowing) {
// return;
// }
// if (!focusNode.hasFocus) {
// focusNode.requestFocus();
// }
// textController.text += '@';
// Future.delayed(Duration.zero, () {
// context
// .read<ChatInputControlCubit>()
// .startSearching(textController.value);
// overlayController.show();
// });
// },
// onTap: onStartMention,
// );
// }
@@ -1,5 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -13,11 +13,9 @@ import 'layout_define.dart';
class PromptInputFile extends StatelessWidget {
const PromptInputFile({
super.key,
required this.chatId,
required this.onDeleted,
});
final String chatId;
final void Function(ChatFile) onDeleted;
@override
@@ -37,7 +35,6 @@ class PromptInputFile extends StatelessWidget {
),
itemCount: files.length,
itemBuilder: (context, index) => ChatFilePreview(
chatId: chatId,
file: files[index],
onDeleted: () => onDeleted(files[index]),
),
@@ -49,13 +46,11 @@ class PromptInputFile extends StatelessWidget {
class ChatFilePreview extends StatefulWidget {
const ChatFilePreview({
required this.chatId,
required this.file,
required this.onDeleted,
super.key,
});
final String chatId;
final ChatFile file;
final VoidCallback onDeleted;
@@ -1,24 +1,22 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package: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:universal_platform/universal_platform.dart';
import '../../service/ai_entities.dart';
import 'layout_define.dart';
class PromptInputDesktopToggleFormatButton extends StatelessWidget {
const PromptInputDesktopToggleFormatButton({
super.key,
required this.showFormatBar,
required this.predefinedFormat,
required this.onTap,
});
final bool showFormatBar;
final PredefinedFormat predefinedFormat;
final VoidCallback onTap;
@override
@@ -50,25 +48,30 @@ class ChangeFormatBar extends StatelessWidget {
required this.predefinedFormat,
required this.spacing,
required this.onSelectPredefinedFormat,
this.showImageFormats = true,
});
final PredefinedFormat? predefinedFormat;
final double spacing;
final void Function(PredefinedFormat) onSelectPredefinedFormat;
final bool showImageFormats;
@override
Widget build(BuildContext context) {
final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true;
return SizedBox(
height: DesktopAIPromptSizes.predefinedFormatButtonHeight,
child: SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => HSpace(spacing),
children: [
_buildFormatButton(context, ImageFormat.text),
_buildFormatButton(context, ImageFormat.textAndImage),
_buildFormatButton(context, ImageFormat.image),
if (predefinedFormat?.imageFormat.hasText ?? true) ...[
_buildDivider(),
if (showImageFormats) ...[
_buildFormatButton(context, ImageFormat.text),
_buildFormatButton(context, ImageFormat.textAndImage),
_buildFormatButton(context, ImageFormat.image),
],
if (showImageFormats && showTextFormats) _buildDivider(),
if (showTextFormats) ...[
_buildTextFormatButton(context, TextFormat.paragraph),
_buildTextFormatButton(context, TextFormat.bulletList),
_buildTextFormatButton(context, TextFormat.numberedList),
@@ -101,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -147,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -0,0 +1,259 @@
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) {
if (state.selectedModel == null) {
return const SizedBox.shrink();
}
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: () => 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,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
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: 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.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),
),
],
),
),
),
),
),
);
}
}
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
@@ -19,9 +18,11 @@ import 'select_sources_menu.dart';
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
const PromptInputMobileSelectSourcesButton({
super.key,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
@override
@@ -36,15 +37,15 @@ class _PromptInputMobileSelectSourcesButtonState
@override
void initState() {
super.initState();
widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
onSelectedSourcesChanged();
});
}
@override
void dispose() {
widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
cubit.close();
super.dispose();
}
@@ -70,56 +71,49 @@ class _PromptInputMobileSelectSourcesButtonState
],
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
return FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
);
},
),
@@ -127,6 +121,12 @@ class _PromptInputMobileSelectSourcesButtonState
},
);
}
void onSelectedSourcesChanged() {
cubit
..updateSelectedSources(widget.selectedSourcesNotifier.value)
..updateSelectedStatus();
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
@@ -2,8 +2,8 @@ import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -24,9 +24,11 @@ import 'mention_page_menu.dart';
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
const PromptInputDesktopSelectSourcesButton({
super.key,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
@override
@@ -42,15 +44,15 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
void initState() {
super.initState();
widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
onSelectedSourcesChanged();
});
}
@override
void dispose() {
widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
cubit.close();
super.dispose();
}
@@ -76,51 +78,53 @@ class _PromptInputDesktopSelectSourcesButtonState
],
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
child: AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
onTap: () => popoverController.show(),
),
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
selectedSourcesNotifier: widget.selectedSourcesNotifier,
onTap: () => popoverController.show(),
),
);
},
),
);
}
void onSelectedSourcesChanged() {
cubit
..updateSelectedSources(widget.selectedSourcesNotifier.value)
..updateSelectedStatus();
}
}
class _IndicatorButton extends StatelessWidget {
const _IndicatorButton({
required this.selectedSourcesNotifier,
required this.onTap,
});
final ValueNotifier<List<String>> selectedSourcesNotifier;
final VoidCallback onTap;
@override
@@ -141,14 +145,22 @@ 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),
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
ValueListenableBuilder(
valueListenable: selectedSourcesNotifier,
builder: (context, selectedSourceIds, _) {
final documentId =
context.read<DocumentBloc?>()?.documentId;
final label = documentId != null &&
selectedSourceIds.length == 1 &&
selectedSourceIds[0] == documentId
? LocaleKeys.chat_currentPage.tr()
: selectedSourceIds.length.toString();
return FlowyText(
state.selectedSourceIds.length.toString(),
fontSize: 14,
label,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
);
@@ -158,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),
),
],
),
@@ -1,4 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.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:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -23,6 +25,23 @@ class PromptInputSendButton extends StatelessWidget {
Widget build(BuildContext context) {
return FlowyIconButton(
width: _buttonSize,
richTooltipText: switch (state) {
SendButtonState.streaming => TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.chat_stopTooltip.tr()} ',
style: context.tooltipTextStyle(),
),
TextSpan(
text: 'ESC',
style: context
.tooltipTextStyle()
?.copyWith(color: Theme.of(context).hintColor),
),
],
),
_ => null,
},
icon: switch (state) {
SendButtonState.enabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
@@ -115,4 +115,9 @@ class KVKeys {
///
/// The value is a json string of [RecentIcons]
static const String recentIcons = 'kRecentIcons';
/// The key for saving compact mode ids for node or databse view
///
/// The value is a json list of id
static const String compactModeIds = 'compactModeIds';
}
@@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:open_filex/open_filex.dart';
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;
typedef OnFailureCallback = void Function(Uri uri);
@@ -38,6 +39,11 @@ Future<bool> afLaunchUri(
);
}
// on Linux, add http scheme to the url if it is not present
if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) {
uri = Uri.parse('https://$url');
}
// try to launch the uri directly
bool result;
try {
+4
View File
@@ -100,6 +100,10 @@ bool get isAuthEnabled {
return false;
}
bool get isLocalAuthEnabled {
return currentCloudType().isLocal;
}
/// Determines if AppFlowy Cloud is enabled.
bool get isAppFlowyCloudEnabled {
return currentCloudType().isAppFlowyCloudEnabled;
@@ -387,8 +387,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
bottom: 4.0,
),
child: FlowySvg(
FlowySvgs.lock_page_s,
color: const Color(0xFFD95A0B),
FlowySvgs.lock_page_fill_s,
blendMode: null,
),
),
);
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
@@ -29,6 +27,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:go_router/go_router.dart';
@@ -59,7 +58,9 @@ class _MobileRowDetailPageState extends State<MobileRowDetailPage> {
late final PageController _pageController;
String get viewId => widget.databaseController.viewId;
RowCache get rowCache => widget.databaseController.rowCache;
FieldController get fieldController =>
widget.databaseController.fieldController;
@@ -380,7 +381,9 @@ class MobileRowDetailPageContentState
late final EditableCellBuilder cellBuilder;
String get viewId => widget.databaseController.viewId;
RowCache get rowCache => widget.databaseController.rowCache;
FieldController get fieldController =>
widget.databaseController.fieldController;
ValueNotifier<String> primaryFieldId = ValueNotifier('');
@@ -542,6 +545,7 @@ class _TitleSkin extends IEditableTextCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
TextCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
@@ -100,7 +100,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
key: ValueKey(currentWorkspaceId),
userProfile: userProfile,
workspaceId: currentWorkspaceId,
currentWorkspaceMemberRole: state.currentWorkspace?.role,
),
const SupportSettingGroup(),
const AboutSettingGroup(),
@@ -36,12 +36,16 @@ class MobileSelectionMenu extends SelectionMenuService {
Alignment _alignment = Alignment.topLeft;
final int itemCountFilter;
final int startOffset;
ValueNotifier<_Position> _positionNotifier = ValueNotifier(_Position.zero);
@override
void dismiss() {
if (_selectionMenuEntry != null) {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
editorState
.removeScrollViewScrolledListener(_checkPositionAfterScrolling);
_positionNotifier.dispose();
}
_selectionMenuEntry?.remove();
@@ -53,23 +57,20 @@ class MobileSelectionMenu extends SelectionMenuService {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_show();
editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling);
completer.complete();
});
return completer.future;
}
void _show() {
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return;
}
calculateSelectionMenuOffset(selectionRects.first);
final (left, top, right, bottom) = getPosition();
final position = _getCurrentPosition();
if (position == null) return;
final editorHeight = editorState.renderBox!.size.height;
final editorWidth = editorState.renderBox!.size.width;
_positionNotifier = ValueNotifier(position);
_selectionMenuEntry = OverlayEntry(
builder: (context) {
return SizedBox(
@@ -80,47 +81,54 @@ class MobileSelectionMenu extends SelectionMenuService {
onTap: dismiss,
child: Stack(
children: [
Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: MobileSelectionMenuWidget(
selectionMenuStyle: style,
singleColumn: singleColumn,
items: selectionMenuItems
..forEach((element) {
if (element is MobileSelectionMenuItem) {
element.deleteSlash = false;
element.deleteKeywords = deleteKeywordsByDefault;
for (final e in element.children) {
e.deleteSlash = deleteSlashByDefault;
e.deleteKeywords = deleteKeywordsByDefault;
e.onSelected = () {
dismiss();
};
}
} else {
element.deleteSlash = deleteSlashByDefault;
element.deleteKeywords = deleteKeywordsByDefault;
element.onSelected = () {
dismiss();
};
}
}),
maxItemInRow: 5,
editorState: editorState,
itemCountFilter: itemCountFilter,
startOffset: startOffset,
menuService: this,
onExit: () {
dismiss();
},
deleteSlashByDefault: deleteSlashByDefault,
),
),
ValueListenableBuilder(
valueListenable: _positionNotifier,
builder: (context, value, _) {
return Positioned(
top: value.top,
bottom: value.bottom,
left: value.left,
right: value.right,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: MobileSelectionMenuWidget(
selectionMenuStyle: style,
singleColumn: singleColumn,
items: selectionMenuItems
..forEach((element) {
if (element is MobileSelectionMenuItem) {
element.deleteSlash = false;
element.deleteKeywords =
deleteKeywordsByDefault;
for (final e in element.children) {
e.deleteSlash = deleteSlashByDefault;
e.deleteKeywords = deleteKeywordsByDefault;
e.onSelected = () {
dismiss();
};
}
} else {
element.deleteSlash = deleteSlashByDefault;
element.deleteKeywords =
deleteKeywordsByDefault;
element.onSelected = () {
dismiss();
};
}
}),
maxItemInRow: 5,
editorState: editorState,
itemCountFilter: itemCountFilter,
startOffset: startOffset,
menuService: this,
onExit: () {
dismiss();
},
deleteSlashByDefault: deleteSlashByDefault,
),
),
);
},
),
],
),
@@ -135,6 +143,34 @@ class MobileSelectionMenu extends SelectionMenuService {
editorState.service.scrollService?.disable();
}
/// the workaround for: editor auto scrolling that will cause wrong position
/// of slash menu
void _checkPositionAfterScrolling() {
final position = _getCurrentPosition();
if (position == null) return;
if (position == _positionNotifier.value) {
Future.delayed(const Duration(milliseconds: 100)).then((_) {
final position = _getCurrentPosition();
if (position == null) return;
if (position != _positionNotifier.value) {
_positionNotifier.value = position;
}
});
} else {
_positionNotifier.value = position;
}
}
_Position? _getCurrentPosition() {
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return null;
}
calculateSelectionMenuOffset(selectionRects.first);
final (left, top, right, bottom) = getPosition();
return _Position(left, top, right, bottom);
}
@override
Alignment get alignment {
return _alignment;
@@ -166,7 +202,6 @@ class MobileSelectionMenu extends SelectionMenuService {
bottom = offset.dy;
break;
}
return (left, top, right, bottom);
}
@@ -186,8 +221,9 @@ class MobileSelectionMenu extends SelectionMenuService {
final bottomRight = rect.bottomRight;
final topRight = rect.topRight;
var offset = bottomRight + menuOffset;
final limitX = editorWidth - menuWidth + editorOffset.dx;
_offset = Offset(
offset.dx,
min(offset.dx, limitX),
offset.dy,
);
@@ -196,10 +232,9 @@ class MobileSelectionMenu extends SelectionMenuService {
offset = topRight - menuOffset;
_alignment = Alignment.bottomLeft;
final limitX = editorWidth - menuWidth;
_offset = Offset(
min(offset.dx, limitX),
MediaQuery.of(context).size.height - offset.dy,
offset.dx,
editorOffset.dy + editorHeight - offset.dy,
);
}
@@ -210,7 +245,6 @@ class MobileSelectionMenu extends SelectionMenuService {
: Alignment.bottomRight;
final x = editorWidth - _offset.dx + editorOffset.dx;
final limitX = editorWidth - menuWidth + editorOffset.dx;
_offset = Offset(
min(x, limitX),
_offset.dy,
@@ -218,3 +252,28 @@ class MobileSelectionMenu extends SelectionMenuService {
}
}
}
class _Position {
const _Position(this.left, this.top, this.right, this.bottom);
final double? left;
final double? top;
final double? right;
final double? bottom;
static const _Position zero = _Position(0, 0, 0, 0);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _Position &&
runtimeType == other.runtimeType &&
left == other.left &&
top == other.top &&
right == other.right &&
bottom == other.bottom;
@override
int get hashCode =>
left.hashCode ^ top.hashCode ^ right.hashCode ^ bottom.hashCode;
}
@@ -36,9 +36,7 @@ class MobileSelectionMenuItemWidget extends StatelessWidget {
),
style: ButtonStyle(
alignment: Alignment.centerLeft,
overlayColor: WidgetStateProperty.all(
style.selectionMenuItemSelectedColor,
),
overlayColor: WidgetStateProperty.all(Colors.transparent),
backgroundColor: isSelected
? WidgetStateProperty.all(
style.selectionMenuItemSelectedColor,
@@ -86,6 +84,15 @@ class MobileSelectionMenuStyle extends SelectionMenuStyle {
required super.selectionMenuItemSelectedTextColor,
required super.selectionMenuItemSelectedIconColor,
required super.selectionMenuItemSelectedColor,
required super.selectionMenuUnselectedLabelColor,
required super.selectionMenuDividerColor,
required super.selectionMenuLinkBorderColor,
required super.selectionMenuInvalidLinkColor,
required super.selectionMenuButtonColor,
required super.selectionMenuButtonTextColor,
required super.selectionMenuButtonIconColor,
required super.selectionMenuButtonBorderColor,
required super.selectionMenuTabIndicatorColor,
required this.selectionMenuItemRightIconColor,
});
@@ -99,6 +106,15 @@ class MobileSelectionMenuStyle extends SelectionMenuStyle {
selectionMenuItemRightIconColor: Color(0xB31E2022),
selectionMenuItemSelectedTextColor: Color.fromARGB(255, 56, 91, 247),
selectionMenuItemSelectedIconColor: Color.fromARGB(255, 56, 91, 247),
selectionMenuUnselectedLabelColor: Color(0xFF333333),
selectionMenuDividerColor: Color(0xFF00BCF0),
selectionMenuLinkBorderColor: Color(0xFF00BCF0),
selectionMenuInvalidLinkColor: Color(0xFFE53935),
selectionMenuButtonColor: Color(0xFF00BCF0),
selectionMenuButtonTextColor: Color(0xFF333333),
selectionMenuButtonIconColor: Color(0xFF333333),
selectionMenuButtonBorderColor: Color(0xFF00BCF0),
selectionMenuTabIndicatorColor: Color(0xFF00BCF0),
);
static const MobileSelectionMenuStyle dark = MobileSelectionMenuStyle(
@@ -109,5 +125,14 @@ class MobileSelectionMenuStyle extends SelectionMenuStyle {
selectionMenuItemRightIconColor: Color(0xB3FFFFFF),
selectionMenuItemSelectedTextColor: Color(0xFF131720),
selectionMenuItemSelectedIconColor: Color(0xFF131720),
selectionMenuUnselectedLabelColor: Color(0xFFBBC3CD),
selectionMenuDividerColor: Color(0xFF3A3F44),
selectionMenuLinkBorderColor: Color(0xFF3A3F44),
selectionMenuInvalidLinkColor: Color(0xFFE53935),
selectionMenuButtonColor: Color(0xFF00BCF0),
selectionMenuButtonTextColor: Color(0xFFFFFFFF),
selectionMenuButtonIconColor: Color(0xFFFFFFFF),
selectionMenuButtonBorderColor: Color(0xFF00BCF0),
selectionMenuTabIndicatorColor: Color(0xFF00BCF0),
);
}
@@ -1,6 +1,8 @@
import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'mobile_selection_menu_item.dart';
@@ -314,15 +316,32 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
}
Widget _buildNoResultsWidget(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(8.0),
return DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 140,
child: Material(
child: Text(
"No results",
style: TextStyle(fontSize: 18.0, color: Colors.grey),
textAlign: TextAlign.center,
width: 240,
height: 48,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Material(
color: Colors.transparent,
child: Center(
child: Text(
LocaleKeys.inlineActions_noResults.tr(),
style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)),
textAlign: TextAlign.center,
),
),
),
),
),
@@ -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,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -18,6 +19,7 @@ class AppFlowyCloudPage extends StatelessWidget {
),
body: SettingCloud(
restartAppFlowy: () async {
await getIt<AuthService>().signOut();
await runAppFlowy();
},
),
@@ -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;
}
}
@@ -23,11 +23,126 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
parseMetadata(refSourceJsonString),
),
) {
_dispatch();
_registerEventHandlers();
_initializeStreamListener();
_checkInitialStreamState();
}
final String chatId;
final Int64? questionId;
void _registerEventHandlers() {
on<_UpdateText>((event, emit) {
emit(
state.copyWith(
text: event.text,
messageState: const MessageState.ready(),
),
);
});
on<_ReceiveError>((event, emit) {
emit(state.copyWith(messageState: MessageState.onError(event.error)));
});
on<_Retry>((event, emit) async {
if (questionId == null) {
Log.error("Question id is not valid: $questionId");
return;
}
emit(state.copyWith(messageState: const MessageState.loading()));
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: questionId,
);
final result = await AIEventGetAnswerForQuestion(payload).send();
if (!isClosed) {
result.fold(
(answer) => add(ChatAIMessageEvent.retryResult(answer.content)),
(err) {
Log.error("Failed to get answer: $err");
add(ChatAIMessageEvent.receiveError(err.toString()));
},
);
}
});
on<_RetryResult>((event, emit) {
emit(
state.copyWith(
text: event.text,
messageState: const MessageState.ready(),
),
);
});
on<_OnAIResponseLimit>((event, emit) {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
),
);
});
on<_OnAIImageResponseLimit>((event, emit) {
emit(
state.copyWith(
messageState: const MessageState.onAIImageResponseLimit(),
),
);
});
on<_OnAIMaxRquired>((event, emit) {
emit(
state.copyWith(
messageState: MessageState.onAIMaxRequired(event.message),
),
);
});
on<_OnLocalAIInitializing>((event, emit) {
emit(
state.copyWith(
messageState: const MessageState.onInitializingLocalAI(),
),
);
});
on<_ReceiveMetadata>((event, emit) {
Log.debug("AI Steps: ${event.metadata.progress?.step}");
emit(
state.copyWith(
sources: event.metadata.sources,
progress: event.metadata.progress,
),
);
});
}
void _initializeStreamListener() {
if (state.stream != null) {
_startListening();
state.stream!.listen(
onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)),
onError: (error) =>
_safeAdd(ChatAIMessageEvent.receiveError(error.toString())),
onAIResponseLimit: () =>
_safeAdd(const ChatAIMessageEvent.onAIResponseLimit()),
onAIImageResponseLimit: () =>
_safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()),
onMetadata: (metadata) =>
_safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)),
onAIMaxRequired: (message) {
Log.info(message);
_safeAdd(ChatAIMessageEvent.onAIMaxRequired(message));
},
onLocalAIInitializing: () =>
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
);
}
}
void _checkInitialStreamState() {
if (state.stream != null) {
if (state.stream!.aiLimitReached) {
add(const ChatAIMessageEvent.onAIResponseLimit());
} else if (state.stream!.error != null) {
@@ -36,130 +151,10 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}
}
final String chatId;
final Int64? questionId;
void _dispatch() {
on<ChatAIMessageEvent>(
(event, emit) {
event.when(
updateText: (newText) {
emit(
state.copyWith(
text: newText,
messageState: const MessageState.ready(),
),
);
},
receiveError: (error) {
emit(state.copyWith(messageState: MessageState.onError(error)));
},
retry: () {
if (questionId is! Int64) {
Log.error("Question id is not Int64: $questionId");
return;
}
emit(
state.copyWith(
messageState: const MessageState.loading(),
),
);
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: questionId,
);
AIEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) {
add(ChatAIMessageEvent.retryResult(answer.content));
},
(err) {
Log.error("Failed to get answer: $err");
add(ChatAIMessageEvent.receiveError(err.toString()));
},
);
}
});
},
retryResult: (String text) {
emit(
state.copyWith(
text: text,
messageState: const MessageState.ready(),
),
);
},
onAIResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
),
);
},
onAIImageResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIImageResponseLimit(),
),
);
},
onAIMaxRequired: (message) {
emit(
state.copyWith(
messageState: MessageState.onAIMaxRequired(message),
),
);
},
receiveMetadata: (metadata) {
Log.debug("AI Steps: ${metadata.progress?.step}");
emit(
state.copyWith(
sources: metadata.sources,
progress: metadata.progress,
),
);
},
);
},
);
}
void _startListening() {
state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
}
},
onError: (error) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(error.toString()));
}
},
onAIResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
onAIImageResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIImageResponseLimit());
}
},
onMetadata: (metadata) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveMetadata(metadata));
}
},
onAIMaxRequired: (message) {
if (!isClosed) {
Log.info(message);
add(ChatAIMessageEvent.onAIMaxRequired(message));
}
},
);
void _safeAdd(ChatAIMessageEvent event) {
if (!isClosed) {
add(event);
}
}
}
@@ -174,6 +169,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
_OnAIImageResponseLimit;
const factory ChatAIMessageEvent.onAIMaxRequired(String message) =
_OnAIMaxRquired;
const factory ChatAIMessageEvent.onLocalAIInitializing() =
_OnLocalAIInitializing;
const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata,
) = _ReceiveMetadata;
@@ -209,6 +206,7 @@ class MessageState with _$MessageState {
const factory MessageState.onAIResponseLimit() = _AIResponseLimit;
const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit;
const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired;
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
const factory MessageState.ready() = _Ready;
const factory MessageState.loading() = _Loading;
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
@@ -10,6 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -28,6 +30,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
required this.userId,
}) : chatController = InMemoryChatController(),
listener = ChatMessageListener(chatId: chatId),
selectedSourcesNotifier = ValueNotifier([]),
super(ChatState.initial()) {
_startListening();
_dispatch();
@@ -38,7 +41,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
final String chatId;
final String userId;
final ChatMessageListener listener;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final ChatController chatController;
/// The last streaming message id
@@ -68,7 +71,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
await listener.stop();
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
selectedSourcesNotifier.dispose();
return super.close();
}
@@ -236,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;
@@ -251,12 +254,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
},
didReceiveChatSettings: (settings) {
emit(
state.copyWith(selectedSourceIds: settings.ragIds),
);
selectedSourcesNotifier.value = settings.ragIds;
},
updateSelectedSources: (selectedSourcesIds) async {
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
selectedSourcesNotifier.value = [...selectedSourcesIds];
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
@@ -482,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)
@@ -504,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) {
@@ -636,6 +641,7 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.regenerateAnswer(
String id,
PredefinedFormat? format,
AIModelPB? model,
) = _RegenerateAnswer;
// streaming answer
@@ -665,14 +671,12 @@ class ChatEvent with _$ChatEvent {
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required List<String> selectedSourceIds,
required LoadChatMessageStatus loadingState,
required PromptResponseState promptResponseState,
required bool clearErrorMessages,
}) = _ChatState;
factory ChatState.initial() => const ChatState(
selectedSourceIds: [],
loadingState: LoadChatMessageStatus.loading,
promptResponseState: PromptResponseState.ready,
clearErrorMessages: false,
@@ -1,11 +1,8 @@
import 'dart:io';
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:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
@@ -140,87 +137,3 @@ enum LoadChatMessageStatus {
loadingRemote,
ready,
}
class PredefinedFormat extends Equatable {
const PredefinedFormat({
required this.imageFormat,
required this.textFormat,
});
const PredefinedFormat.auto()
: imageFormat = ImageFormat.text,
textFormat = TextFormat.paragraph;
final ImageFormat imageFormat;
final TextFormat? textFormat;
PredefinedFormatPB toPB() {
return PredefinedFormatPB(
imageFormat: switch (imageFormat) {
ImageFormat.text => ResponseImageFormatPB.TextOnly,
ImageFormat.image => ResponseImageFormatPB.ImageOnly,
ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
},
textFormat: switch (textFormat) {
TextFormat.paragraph => ResponseTextFormatPB.Paragraph,
TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
TextFormat.table => ResponseTextFormatPB.Table,
_ => null,
},
);
}
@override
List<Object?> get props => [imageFormat, textFormat];
}
enum ImageFormat {
text,
image,
textAndImage;
bool get hasText => this == text || this == textAndImage;
FlowySvgData get icon {
return switch (this) {
ImageFormat.text => FlowySvgs.ai_text_s,
ImageFormat.image => FlowySvgs.ai_image_s,
ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
};
}
String get i18n {
return switch (this) {
ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
ImageFormat.textAndImage =>
LocaleKeys.chat_changeFormat_textAndImage.tr(),
};
}
}
enum TextFormat {
paragraph,
bulletList,
numberedList,
table;
FlowySvgData get icon {
return switch (this) {
TextFormat.paragraph => FlowySvgs.ai_paragraph_s,
TextFormat.bulletList => FlowySvgs.ai_list_s,
TextFormat.numberedList => FlowySvgs.ai_number_list_s,
TextFormat.table => FlowySvgs.ai_table_s,
};
}
String get i18n {
return switch (this) {
TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(),
TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
};
}
}
@@ -2,55 +2,25 @@ import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
/// A stream that receives answer events from an isolate or external process.
/// It caches events that might occur before a listener is attached.
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
_onData?.call(_text);
} else if (event.startsWith("error:")) {
_error = event.substring(5);
_onError?.call(_error!);
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
_onMetadata!(parseMetadata(s));
}
} else if (event == "AI_RESPONSE_LIMIT") {
_aiLimitReached = true;
_onAIResponseLimit?.call();
} else if (event == "AI_IMAGE_RESPONSE_LIMIT") {
_aiImageLimitReached = true;
_onAIImageResponseLimit?.call();
} else if (event.startsWith("AI_MAX_REQUIRED:")) {
final msg = event.substring(16);
// If the callback is not registered yet, add the event to the buffer.
if (_onAIMaxRequired != null) {
_onAIMaxRequired!(msg);
} else {
_pendingAIMaxRequiredEvents.add(msg);
}
}
},
onDone: () {
_onEnd?.call();
},
onError: (error) {
_error = error.toString();
_onError?.call(error.toString());
},
_handleEvent,
onDone: _onDoneCallback,
onError: _handleError,
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
bool _aiLimitReached = false;
bool _aiImageLimitReached = false;
@@ -62,13 +32,15 @@ class AnswerStream {
void Function()? _onStart;
void Function()? _onEnd;
void Function(String error)? _onError;
void Function()? _onLocalAIInitializing;
void Function()? _onAIResponseLimit;
void Function()? _onAIImageResponseLimit;
void Function(String message)? _onAIMaxRequired;
void Function(MetadataCollection metadataCollection)? _onMetadata;
void Function(MetadataCollection metadata)? _onMetadata;
// Buffer for events that occur before listen() is called.
// Caches for events that occur before listen() is called.
final List<String> _pendingAIMaxRequiredEvents = [];
bool _pendingLocalAINotReady = false;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
@@ -77,12 +49,61 @@ class AnswerStream {
String? get error => _error;
String get text => _text;
/// Releases the resources used by the AnswerStream.
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
/// Handles incoming events from the underlying stream.
void _handleEvent(String event) {
if (event.startsWith(AIStreamEventPrefix.data)) {
_hasStarted = true;
final newText = event.substring(AIStreamEventPrefix.data.length);
_text += newText;
_onData?.call(_text);
} else if (event.startsWith(AIStreamEventPrefix.error)) {
_error = event.substring(AIStreamEventPrefix.error.length);
_onError?.call(_error!);
} else if (event.startsWith(AIStreamEventPrefix.metadata)) {
final s = event.substring(AIStreamEventPrefix.metadata.length);
_onMetadata?.call(parseMetadata(s));
} else if (event == AIStreamEventPrefix.aiResponseLimit) {
_aiLimitReached = true;
_onAIResponseLimit?.call();
} else if (event == AIStreamEventPrefix.aiImageResponseLimit) {
_aiImageLimitReached = true;
_onAIImageResponseLimit?.call();
} else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length);
if (_onAIMaxRequired != null) {
_onAIMaxRequired!(msg);
} else {
_pendingAIMaxRequiredEvents.add(msg);
}
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
if (_onLocalAIInitializing != null) {
_onLocalAIInitializing!();
} else {
_pendingLocalAINotReady = true;
}
}
}
void _onDoneCallback() {
_onEnd?.call();
}
void _handleError(dynamic error) {
_error = error.toString();
_onError?.call(_error!);
}
/// Registers listeners for various events.
///
/// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY),
/// they will be flushed immediately.
void listen({
void Function(String text)? onData,
void Function()? onStart,
@@ -92,6 +113,7 @@ class AnswerStream {
void Function()? onAIImageResponseLimit,
void Function(String message)? onAIMaxRequired,
void Function(MetadataCollection metadata)? onMetadata,
void Function()? onLocalAIInitializing,
}) {
_onData = onData;
_onStart = onStart;
@@ -99,10 +121,11 @@ class AnswerStream {
_onError = onError;
_onAIResponseLimit = onAIResponseLimit;
_onAIImageResponseLimit = onAIImageResponseLimit;
_onMetadata = onMetadata;
_onAIMaxRequired = onAIMaxRequired;
_onMetadata = onMetadata;
_onLocalAIInitializing = onLocalAIInitializing;
// Flush any buffered AI_MAX_REQUIRED events.
// Flush pending AI_MAX_REQUIRED events.
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
for (final msg in _pendingAIMaxRequiredEvents) {
_onAIMaxRequired!(msg);
@@ -110,6 +133,12 @@ class AnswerStream {
_pendingAIMaxRequiredEvents.clear();
}
// Flush pending LOCAL_AI_NOT_READY event.
if (_pendingLocalAINotReady && _onLocalAIInitializing != null) {
_onLocalAIInitializing!();
_pendingLocalAINotReady = false;
}
_onStart?.call();
}
}
@@ -19,9 +19,17 @@ class ChatSelectMessageBloc
on<ChatSelectMessageEvent>(
(event, emit) {
event.when(
enableStartSelectingMessages: () {
emit(state.copyWith(enabled: true));
},
toggleSelectingMessages: () {
if (state.isSelectingMessages) {
emit(ChatSelectMessageState.initial());
emit(
state.copyWith(
isSelectingMessages: false,
selectedMessages: [],
),
);
} else {
emit(state.copyWith(isSelectingMessages: true));
}
@@ -50,8 +58,13 @@ class ChatSelectMessageBloc
unselectAllMessages: () {
emit(state.copyWith(selectedMessages: const []));
},
saveAsPage: () {
emit(ChatSelectMessageState.initial());
reset: () {
emit(
state.copyWith(
isSelectingMessages: false,
selectedMessages: [],
),
);
},
);
},
@@ -70,6 +83,8 @@ class ChatSelectMessageBloc
@freezed
class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
const factory ChatSelectMessageEvent.enableStartSelectingMessages() =
_EnableStartSelectingMessages;
const factory ChatSelectMessageEvent.toggleSelectingMessages() =
_ToggleSelectingMessages;
const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) =
@@ -79,7 +94,7 @@ class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
) = _SelectAllMessages;
const factory ChatSelectMessageEvent.unselectAllMessages() =
_UnselectAllMessages;
const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage;
const factory ChatSelectMessageEvent.reset() = _Reset;
}
@freezed
@@ -87,9 +102,11 @@ class ChatSelectMessageState with _$ChatSelectMessageState {
const factory ChatSelectMessageState({
required bool isSelectingMessages,
required List<Message> selectedMessages,
required bool enabled,
}) = _ChatSelectMessageState;
factory ChatSelectMessageState.initial() => const ChatSelectMessageState(
enabled: false,
isSelectingMessages: false,
selectedMessages: [],
);
@@ -113,9 +113,8 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
String filter = '';
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds
..clear
..addAll(newSelectedSourceIds);
selectedSourceIds.clear();
selectedSourceIds.addAll(newSelectedSourceIds);
}
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
@@ -178,8 +178,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
customActions: [
CustomViewAction(
view: notifier.view,
disabled: !state.enabled,
leftIcon: FlowySvgs.ai_add_to_page_s,
label: LocaleKeys.moreAction_saveAsNewPage.tr(),
tooltipMessage: state.enabled
? null
: LocaleKeys.moreAction_saveAsNewPageDisabled.tr(),
onTap: () {
chatMessageSelectorBloc.add(
const ChatSelectMessageEvent
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
@@ -11,6 +12,7 @@ import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
@@ -19,14 +21,12 @@ import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.dart';
import 'application/ai_prompt_input_bloc.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_select_message_bloc.dart';
import 'application/chat_message_stream.dart';
import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/desktop_chat_input.dart';
import 'presentation/chat_input/mobile_chat_input.dart';
import 'presentation/chat_related_question.dart';
import 'presentation/chat_welcome_page.dart';
@@ -71,7 +71,15 @@ class AIChatPage extends StatelessWidget {
),
/// [AIPromptInputBloc] is used to handle the user prompt
BlocProvider(create: (_) => AIPromptInputBloc()),
BlocProvider(
create: (_) => AIPromptInputBloc(
objectId: view.id,
predefinedFormat: PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.bulletList,
),
),
),
BlocProvider(create: (_) => ChatMemberBloc()),
],
child: Builder(
@@ -86,9 +94,29 @@ class AIChatPage extends StatelessWidget {
}
}
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
child: FocusScope(
onKeyEvent: (focusNode, event) {
if (event is! KeyUpEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.escape ||
event.logicalKey == LogicalKeyboardKey.keyC &&
HardwareKeyboard.instance.isControlPressed) {
final chatBloc = context.read<ChatBloc>();
if (chatBloc.state.promptResponseState !=
PromptResponseState.ready) {
chatBloc.add(ChatEvent.stopStream());
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: _ChatContentPage(
view: view,
userProfile: userProfile,
),
),
);
},
@@ -146,7 +174,7 @@ class _ChatContentPage extends StatelessWidget {
),
),
_wrapConstraints(
_builtInput(context),
_Input(view: view),
),
],
),
@@ -184,26 +212,25 @@ class _ChatContentPage extends StatelessWidget {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
context
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question));
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
if (message.author.id == userProfile.id.toString()) {
if (message.author.id == userProfile.id.toString() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
isCurrentUser: true,
);
}
if (isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
isCurrentUser: false,
);
}
@@ -238,10 +265,16 @@ class _ChatContentPage extends StatelessWidget {
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null)),
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)),
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
);
},
);
@@ -286,11 +319,24 @@ class _ChatContentPage extends StatelessWidget {
return ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (question) {
bloc.add(ChatEvent.sendMessage(message: question));
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats =
aiPromptInputBloc.state.showPredefinedFormats;
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
bloc.add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
@@ -308,7 +354,62 @@ class _ChatContentPage extends StatelessWidget {
);
}
Widget _builtInput(BuildContext context) {
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
// When the source of metatdata is appflowy, which means it is a appflowy page
if (metadata.source == "appflowy") {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
return;
}
if (metadata.source == "web") {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
}
return;
}
}
}
class _Input extends StatefulWidget {
const _Input({
required this.view,
});
final ViewPB view;
@override
State<_Input> createState() => _InputState();
}
class _InputState extends State<_Input> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
@@ -334,9 +435,9 @@ class _ChatContentPage extends StatelessWidget {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopChatInput(
chatId: view.id,
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
@@ -349,6 +450,8 @@ class _ChatContentPage extends StatelessWidget {
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
@@ -358,7 +461,6 @@ class _ChatContentPage extends StatelessWidget {
},
)
: MobileChatInput(
chatId: view.id,
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
@@ -372,6 +474,8 @@ class _ChatContentPage extends StatelessWidget {
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
@@ -387,30 +491,4 @@ class _ChatContentPage extends StatelessWidget {
},
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
} else {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
}
}
}
@@ -1,8 +1,7 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
@@ -10,21 +9,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
class MobileChatInput extends StatefulWidget {
const MobileChatInput({
super.key,
required this.chatId,
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final String chatId;
final bool isStreaming;
final void Function() onStopStreaming;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final void Function(List<String>) onUpdateSelectedSources;
@@ -38,18 +35,13 @@ class _MobileChatInputState extends State<MobileChatInput> {
final focusNode = FocusNode();
final textController = TextEditingController();
bool showPredefinedFormatSection = true;
PredefinedFormat predefinedFormat = const PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.bulletList,
);
late SendButtonState sendButtonState;
@override
void initState() {
super.initState();
textController.addListener(handleTextControllerChange);
textController.addListener(handleTextControllerChanged);
// focusNode.onKeyEvent = handleKeyEvent;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -106,53 +98,62 @@ class _MobileChatInputState extends State<MobileChatInput> {
borderRadius:
const BorderRadius.vertical(top: Radius.circular(8.0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MobileAIPromptSizes.attachedFilesBarPadding.vertical +
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MobileAIPromptSizes
.attachedFilesBarPadding.vertical +
MobileAIPromptSizes.attachedFilesPreviewHeight,
),
child: PromptInputFile(
chatId: widget.chatId,
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
),
),
if (showPredefinedFormatSection)
TextFieldTapRegion(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ChangeFormatBar(
predefinedFormat: predefinedFormat,
spacing: 8.0,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
),
child: PromptInputFile(
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
),
),
)
else
const VSpace(8.0),
inputTextField(context),
TextFieldTapRegion(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const HSpace(8.0),
leadingButtons(context),
const Spacer(),
sendButton(),
const HSpace(12.0),
],
if (state.showPredefinedFormats)
TextFieldTapRegion(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ChangeFormatBar(
predefinedFormat: state.predefinedFormat,
spacing: 8.0,
onSelectPredefinedFormat: (format) =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent.updatePredefinedFormat(
format,
),
),
),
),
)
else
const VSpace(8.0),
inputTextField(context),
TextFieldTapRegion(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const HSpace(8.0),
leadingButtons(
context,
state.showPredefinedFormats,
),
const Spacer(),
sendButton(),
const HSpace(12.0),
],
),
),
),
),
),
],
],
);
},
),
),
),
@@ -185,14 +186,18 @@ class _MobileChatInputState extends State<MobileChatInput> {
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
widget.onSubmitted(
trimmedText,
showPredefinedFormatSection ? predefinedFormat : null,
showPredefinedFormats ? predefinedFormat : null,
metadata,
);
}
void handleTextControllerChange() {
void handleTextControllerChanged() {
if (textController.value.isComposingRangeValid) {
return;
}
@@ -263,10 +268,10 @@ class _MobileChatInputState extends State<MobileChatInput> {
focusedBorder: InputBorder.none,
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
AiType.cloud => LocaleKeys.chat_inputMessageHint.tr(),
AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
hintStyle: AIChatUILayout.inputHintTextStyle(context),
hintStyle: inputHintTextStyle(context),
isCollapsed: true,
isDense: true,
),
@@ -289,7 +294,15 @@ class _MobileChatInputState extends State<MobileChatInput> {
);
}
Widget leadingButtons(BuildContext context) {
TextStyle? inputHintTextStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).isLightMode
? const Color(0xFFBDC2C8)
: const Color(0xFF3C3E51),
);
}
Widget leadingButtons(BuildContext context, bool showPredefinedFormats) {
return _LeadingActions(
// onMention: () {
// textController.text += '@';
@@ -300,13 +313,13 @@ class _MobileChatInputState extends State<MobileChatInput> {
// mentionPage(context);
// });
// },
showPredefinedFormatSection: showPredefinedFormatSection,
predefinedFormat: predefinedFormat,
showPredefinedFormats: showPredefinedFormats,
onTogglePredefinedFormatSection: () {
setState(() {
showPredefinedFormatSection = !showPredefinedFormatSection;
});
context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.toggleShowPredefinedFormat());
},
selectedSourcesNotifier: widget.selectedSourcesNotifier,
onUpdateSelectedSources: widget.onUpdateSelectedSources,
);
}
@@ -322,15 +335,15 @@ class _MobileChatInputState extends State<MobileChatInput> {
class _LeadingActions extends StatelessWidget {
const _LeadingActions({
required this.showPredefinedFormatSection,
required this.predefinedFormat,
required this.showPredefinedFormats,
required this.onTogglePredefinedFormatSection,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final bool showPredefinedFormatSection;
final PredefinedFormat predefinedFormat;
final bool showPredefinedFormats;
final void Function() onTogglePredefinedFormatSection;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
@override
@@ -342,10 +355,11 @@ class _LeadingActions extends StatelessWidget {
separatorBuilder: () => const HSpace(4.0),
children: [
PromptInputMobileSelectSourcesButton(
selectedSourcesNotifier: selectedSourcesNotifier,
onUpdateSelectedSources: onUpdateSelectedSources,
),
PromptInputMobileToggleFormatButton(
showFormatBar: showPredefinedFormatSection,
showFormatBar: showPredefinedFormats,
onTap: onTogglePredefinedFormatSection,
),
],
@@ -250,7 +250,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
showSaveMessageSuccessToast(context, view);
}
bloc.add(const ChatSelectMessageEvent.saveAsPage());
bloc.add(const ChatSelectMessageEvent.reset());
return view;
}
@@ -275,7 +275,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
showSaveMessageSuccessToast(context, newView);
openPageFromMessage(context, newView);
}
bloc.add(const ChatSelectMessageEvent.saveAsPage());
bloc.add(const ChatSelectMessageEvent.reset());
}
Future<void> forceReload(String documentId) async {
@@ -21,33 +21,35 @@ class RelatedQuestionList extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length + 1,
padding:
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
separatorBuilder: (context, index) => const VSpace(4.0),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyText(
LocaleKeys.chat_relatedQuestion.tr(),
color: Theme.of(context).hintColor,
fontWeight: FontWeight.w600,
),
);
} else {
return Align(
alignment: AlignmentDirectional.centerStart,
child: RelatedQuestionItem(
question: relatedQuestions[index - 1],
onQuestionSelected: onQuestionSelected,
),
);
}
},
return SelectionContainer.disabled(
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length + 1,
padding:
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
separatorBuilder: (context, index) => const VSpace(4.0),
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyText(
LocaleKeys.chat_relatedQuestion.tr(),
color: Theme.of(context).hintColor,
fontWeight: FontWeight.w600,
),
);
} else {
return Align(
alignment: AlignmentDirectional.centerStart,
child: RelatedQuestionItem(
question: relatedQuestions[index - 1],
onQuestionSelected: onQuestionSelected,
),
);
}
},
),
);
}
}
@@ -70,6 +72,7 @@ class RelatedQuestionItem extends StatelessWidget {
child: FlowyText(
question,
lineHeight: 1.4,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
@@ -1,4 +1,3 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
@@ -20,14 +19,6 @@ class AIChatUILayout {
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
? const EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero;
static TextStyle? inputHintTextStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).isLightMode
? const Color(0xFFBDC2C8)
: const Color(0xFF3C3E51),
);
}
}
class DesktopAIChatSizes {
@@ -1,9 +1,9 @@
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/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -0,0 +1,145 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
Future<AIModelPB?> showChangeModelBottomSheet(
BuildContext context,
List<AIModelPB> models,
) {
return showMobileBottomSheet<AIModelPB?>(
context,
showDragHandle: true,
builder: (context) => _ChangeModelBottomSheetContent(models: models),
);
}
class _ChangeModelBottomSheetContent extends StatefulWidget {
const _ChangeModelBottomSheetContent({
required this.models,
});
final List<AIModelPB> models;
@override
State<_ChangeModelBottomSheetContent> createState() =>
_ChangeModelBottomSheetContentState();
}
class _ChangeModelBottomSheetContentState
extends State<_ChangeModelBottomSheetContent> {
AIModelPB? model;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(
onCancel: () => Navigator.of(context).pop(),
onDone: () => Navigator.of(context).pop(model),
),
const VSpace(4.0),
_Body(
models: widget.models,
selectedModel: model,
onSelectModel: (format) {
setState(() => model = format);
},
),
const VSpace(16.0),
],
);
}
}
class _Header extends StatelessWidget {
const _Header({
required this.onCancel,
required this.onDone,
});
final VoidCallback onCancel;
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44.0,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: AppBarBackButton(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
onTap: onCancel,
),
),
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: FlowyText(
LocaleKeys.chat_switchModel_label.tr(),
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
Align(
alignment: Alignment.centerRight,
child: AppBarDoneButton(
onTap: onDone,
),
),
],
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.models,
required this.selectedModel,
required this.onSelectModel,
});
final List<AIModelPB> models;
final AIModelPB? selectedModel;
final void Function(AIModelPB) onSelectModel;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: models
.mapIndexed(
(index, model) => _buildModelButton(model, index == 0),
)
.toList(),
);
}
Widget _buildModelButton(
AIModelPB model, [
bool isFirst = false,
]) {
return FlowyOptionTile.checkbox(
text: model.name,
isSelected: model == selectedModel,
showTopBorder: isFirst,
onTap: () {
onSelectModel(model);
},
);
}
}
@@ -64,8 +64,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
editorState.dispose();
editorState = _parseMarkdown(widget.markdown.trim());
final editorState = _parseMarkdown(
widget.markdown.trim(),
previousDocument: this.editorState.document,
);
this.editorState.dispose();
this.editorState = editorState;
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
@@ -129,8 +133,30 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
);
}
EditorState _parseMarkdown(String markdown) {
EditorState _parseMarkdown(
String markdown, {
Document? previousDocument,
}) {
// merge the nodes from the previous document with the new document to keep the same node ids
final document = customMarkdownToDocument(markdown);
final documentIterator = NodeIterator(
document: document,
startNode: document.root,
);
if (previousDocument != null) {
final previousDocumentIterator = NodeIterator(
document: previousDocument,
startNode: previousDocument.root,
);
while (
documentIterator.moveNext() && previousDocumentIterator.moveNext()) {
final currentNode = documentIterator.current;
final previousNode = previousDocumentIterator.current;
if (currentNode.path.equals(previousNode.path)) {
currentNode.id = previousNode.id;
}
}
}
final editorState = EditorState(document: document);
return editorState;
}
@@ -1,14 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/ai/widgets/prompt_input/layout_define.dart';
import 'package:appflowy/ai/widgets/prompt_input/predefined_format_buttons.dart';
import 'package:appflowy/ai/widgets/prompt_input/select_sources_menu.dart';
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/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
@@ -24,6 +21,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@@ -44,6 +42,7 @@ class AIMessageActionBar extends StatefulWidget {
required this.showDecoration,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.onOverrideVisibility,
});
@@ -51,6 +50,7 @@ class AIMessageActionBar extends StatefulWidget {
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
final void Function(bool)? onOverrideVisibility;
@override
@@ -129,6 +129,12 @@ class _AIMessageActionBarState extends State<AIMessageActionBar> {
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
ChangeModelButton(
isInHoverBar: widget.showDecoration,
onRegenerate: widget.onChangeModel,
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
SaveToPageButton(
textMessage: widget.message as TextMessage,
isInHoverBar: widget.showDecoration,
@@ -220,7 +226,7 @@ class RegenerateButton extends StatelessWidget {
? DesktopAIChatSizes.messageHoverActionBarIconRadius
: DesktopAIChatSizes.messageActionBarIconRadius,
icon: FlowySvg(
FlowySvgs.ai_undo_s,
FlowySvgs.ai_try_again_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
@@ -263,8 +269,11 @@ class _ChangeFormatButtonState extends State<ChangeFormatButton> {
constraints: const BoxConstraints(),
onClose: () => widget.onOverrideVisibility?.call(false),
child: buildButton(context),
popupBuilder: (_) => _ChangeFormatPopoverContent(
onRegenerate: widget.onRegenerate,
popupBuilder: (_) => BlocProvider.value(
value: context.read<AIPromptInputBloc>(),
child: _ChangeFormatPopoverContent(
onRegenerate: widget.onRegenerate,
),
),
);
}
@@ -362,11 +371,16 @@ class _ChangeFormatPopoverContentState
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ChangeFormatBar(
spacing: 2.0,
predefinedFormat: predefinedFormat,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return ChangeFormatBar(
spacing: 2.0,
showImageFormats: state.aiType.isCloud,
predefinedFormat: predefinedFormat,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
);
},
),
const HSpace(4.0),
@@ -400,6 +414,85 @@ class _ChangeFormatPopoverContentState
}
}
class ChangeModelButton extends StatefulWidget {
const ChangeModelButton({
super.key,
required this.isInHoverBar,
this.popoverMutex,
this.onRegenerate,
this.onOverrideVisibility,
});
final bool isInHoverBar;
final PopoverMutex? popoverMutex;
final void Function(AIModelPB)? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
State<ChangeModelButton> createState() => _ChangeModelButtonState();
}
class _ChangeModelButtonState extends State<ChangeModelButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
controller: popoverController,
mutex: widget.popoverMutex,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
onClose: () => widget.onOverrideVisibility?.call(false),
child: buildButton(context),
popupBuilder: (_) {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
return SelectModelPopoverContent(
models: models,
selectedModel: null,
onSelectModel: widget.onRegenerate,
);
},
);
}
Widget buildButton(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_switchModel_label.tr(),
child: FlowyIconButton(
width: 32.0,
height: DesktopAIChatSizes.messageActionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIChatSizes.messageHoverActionBarIconRadius
: DesktopAIChatSizes.messageActionBarIconRadius,
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
onPressed: () {
widget.onOverrideVisibility?.call(true);
popoverController.show();
},
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
@@ -1,17 +1,18 @@
import 'dart:convert';
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/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -22,8 +23,8 @@ import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../../../../ai/widgets/prompt_input/mention_page_bottom_sheet.dart';
import '../layout_define.dart';
import 'ai_change_model_bottom_sheet.dart';
import 'ai_message_action_bar.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'message_util.dart';
@@ -42,6 +43,7 @@ class ChatAIMessageBubble extends StatelessWidget {
this.isSelectingMessages = false,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Message message;
@@ -51,6 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget {
final bool isSelectingMessages;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@@ -74,6 +77,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -83,6 +87,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -92,6 +97,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -104,12 +110,14 @@ class ChatAIBottomInlineActions extends StatelessWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@@ -128,6 +136,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
showDecoration: false,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
),
const VSpace(32.0),
@@ -143,12 +152,14 @@ class ChatAIMessageHover extends StatefulWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@@ -218,22 +229,23 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
setState(() => hoverActionBar = false);
}
},
child: Container(
constraints: BoxConstraints(
maxWidth: 784,
maxHeight: DesktopAIChatSizes.messageActionBarIconSize +
DesktopAIChatSizes
.messageHoverActionBarPadding.vertical,
),
child: SizedBox(
width: 784,
height: DesktopAIChatSizes.messageActionBarIconSize +
DesktopAIChatSizes.messageHoverActionBarPadding.vertical,
child: hoverBubble || hoverActionBar || overrideVisibility
? AIMessageActionBar(
message: widget.message,
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
? Align(
alignment: AlignmentDirectional.centerStart,
child: AIMessageActionBar(
message: widget.message,
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onChangeModel: widget.onChangeModel,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
),
)
: null,
),
@@ -303,12 +315,14 @@ class ChatAIMessagePopup extends StatelessWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@@ -329,6 +343,8 @@ class ChatAIMessagePopup extends StatelessWidget {
_divider(),
_changeFormatButton(context),
_divider(),
_changeModelButton(context),
_divider(),
_saveToPageButton(context),
],
);
@@ -377,7 +393,7 @@ class ChatAIMessagePopup extends StatelessWidget {
onRegenerate?.call();
Navigator.of(context).pop();
},
icon: FlowySvgs.ai_undo_s,
icon: FlowySvgs.ai_try_again_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_regenerate.tr(),
);
@@ -400,6 +416,25 @@ class ChatAIMessagePopup extends StatelessWidget {
);
}
Widget _changeModelButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
final result = await showChangeModelBottomSheet(context, models);
if (result != null) {
onChangeModel?.call(result);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
icon: FlowySvgs.ai_sparks_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_switchModel_label.tr(),
);
}
Widget _saveToPageButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
@@ -470,7 +505,9 @@ class _WrapIsSelectingMessage extends StatelessWidget {
if (isSelectingMessages)
ChatSelectMessageIndicator(isSelected: isSelected)
else
const ChatAIAvatar(),
SelectionContainer.disabled(
child: const ChatAIAvatar(),
),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
Expanded(
child: IgnorePointer(
@@ -1,9 +1,10 @@
import 'package:appflowy/ai/widgets/loading_indicator.dart';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -32,9 +33,11 @@ class ChatAIMessageWidget extends StatelessWidget {
required this.questionId,
required this.chatId,
required this.refSourceJsonString,
required this.onStopStream,
this.onSelectedMetadata,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
@@ -50,7 +53,9 @@ class ChatAIMessageWidget extends StatelessWidget {
final String? refSourceJsonString;
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
final void Function()? onRegenerate;
final void Function() onStopStream;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
@@ -84,14 +89,20 @@ class ChatAIMessageWidget extends StatelessWidget {
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: AILoadingIndicator(text: loadingText),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
),
ready: () {
return state.text.isEmpty
? ChatAIMessageBubble(
message: message,
showActions: false,
child: AILoadingIndicator(text: loadingText),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
)
: ChatAIMessageBubble(
message: message,
@@ -102,14 +113,19 @@ class ChatAIMessageWidget extends StatelessWidget {
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(markdown: state.text),
AIMarkdownText(
markdown: state.text,
),
if (state.sources.isNotEmpty)
AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage)
const VSpace(8.0),
@@ -138,6 +154,15 @@ class ChatAIMessageWidget extends StatelessWidget {
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage: LocaleKeys
.settings_aiPage_keys_localAIInitializing
.tr(),
);
},
),
),
);
@@ -15,13 +15,11 @@ class ChatUserMessageBubble extends StatelessWidget {
super.key,
required this.message,
required this.child,
required this.isCurrentUser,
this.files = const [],
});
final Message message;
final Widget child;
final bool isCurrentUser;
final List<ChatFile> files;
@override
@@ -44,40 +42,28 @@ class ChatUserMessageBubble extends StatelessWidget {
const VSpace(6),
],
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: getChildren(context),
mainAxisAlignment: MainAxisAlignment.end,
children: [
_buildBubble(context),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
_buildAvatar(),
],
),
],
),
);
}
List<Widget> getChildren(BuildContext context) {
if (isCurrentUser) {
return [
const Spacer(),
_buildBubble(context),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
_buildAvatar(),
];
} else {
return [
_buildAvatar(),
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
_buildBubble(context),
const Spacer(),
];
}
}
Widget _buildAvatar() {
return BlocBuilder<ChatMemberBloc, ChatMemberState>(
builder: (context, state) {
final member = state.members[message.author.id];
return ChatUserAvatar(
iconUrl: member?.info.avatarUrl ?? "",
name: member?.info.name ?? "",
return SelectionContainer.disabled(
child: ChatUserAvatar(
iconUrl: member?.info.avatarUrl ?? "",
name: member?.info.name ?? "",
),
);
},
);
@@ -15,12 +15,10 @@ class ChatUserMessageWidget extends StatelessWidget {
super.key,
required this.user,
required this.message,
required this.isCurrentUser,
});
final User user;
final TextMessage message;
final bool isCurrentUser;
@override
Widget build(BuildContext context) {
@@ -34,7 +32,6 @@ class ChatUserMessageWidget extends StatelessWidget {
),
child: ChatUserMessageBubble(
message: message,
isCurrentUser: isCurrentUser,
files: _getFiles(),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
@@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
if (state.content != text) {
emit(state.copyWith(content: text));
await cellController.saveCellData(text);
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
// So for every cell data that will be formatted in the backend.
// It needs to get the formatted data after saving.
add(
NumberCellEvent.didReceiveCellUpdate(
cellController.getCellData(),
),
);
}
},
);
@@ -1,7 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/application/view/view_cache.dart';
import 'package:appflowy/plugins/database/domain/database_view_service.dart';
@@ -14,6 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'defines.dart';
import 'row/row_cache.dart';
@@ -41,7 +40,9 @@ class GroupCallbacks {
}
class DatabaseLayoutSettingCallbacks {
DatabaseLayoutSettingCallbacks({required this.onLayoutSettingsChanged});
DatabaseLayoutSettingCallbacks({
required this.onLayoutSettingsChanged,
});
final void Function(DatabaseLayoutSettingPB) onLayoutSettingsChanged;
}
@@ -97,9 +98,11 @@ class DatabaseController {
final List<DatabaseCallbacks> _databaseCallbacks = [];
final List<GroupCallbacks> _groupCallbacks = [];
final List<DatabaseLayoutSettingCallbacks> _layoutCallbacks = [];
final Set<ValueChanged<bool>> _compactModeCallbacks = {};
// Getters
RowCache get rowCache => _viewCache.rowCache;
String get viewId => view.id;
// Listener
@@ -107,17 +110,26 @@ class DatabaseController {
final DatabaseLayoutSettingListener _layoutListener;
final ValueNotifier<bool> _isLoading = ValueNotifier(true);
final ValueNotifier<bool> _compactMode = ValueNotifier(true);
void setIsLoading(bool isLoading) {
_isLoading.value = isLoading;
}
void setIsLoading(bool isLoading) => _isLoading.value = isLoading;
ValueNotifier<bool> get isLoading => _isLoading;
void setCompactMode(bool compactMode) {
_compactMode.value = compactMode;
for (final callback in Set.of(_compactModeCallbacks)) {
callback.call(compactMode);
}
}
ValueNotifier<bool> get compactModeNotifier => _compactMode;
void addListener({
DatabaseCallbacks? onDatabaseChanged,
DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged,
GroupCallbacks? onGroupChanged,
ValueChanged<bool>? onCompactModeChanged,
}) {
if (onLayoutSettingsChanged != null) {
_layoutCallbacks.add(onLayoutSettingsChanged);
@@ -130,12 +142,17 @@ class DatabaseController {
if (onGroupChanged != null) {
_groupCallbacks.add(onGroupChanged);
}
if (onCompactModeChanged != null) {
_compactModeCallbacks.add(onCompactModeChanged);
}
}
void removeListener({
DatabaseCallbacks? onDatabaseChanged,
DatabaseLayoutSettingCallbacks? onLayoutSettingsChanged,
GroupCallbacks? onGroupChanged,
ValueChanged<bool>? onCompactModeChanged,
}) {
if (onDatabaseChanged != null) {
_databaseCallbacks.remove(onDatabaseChanged);
@@ -148,6 +165,10 @@ class DatabaseController {
if (onGroupChanged != null) {
_groupCallbacks.remove(onGroupChanged);
}
if (onCompactModeChanged != null) {
_compactModeCallbacks.remove(onCompactModeChanged);
}
}
Future<FlowyResult<void, FlowyError>> open() async {
@@ -242,6 +263,7 @@ class DatabaseController {
_databaseCallbacks.clear();
_groupCallbacks.clear();
_layoutCallbacks.clear();
_compactModeCallbacks.clear();
_isLoading.dispose();
}
@@ -376,4 +398,10 @@ class DatabaseController {
},
);
}
void initCompactMode(bool enableCompactMode) {
if (_compactMode.value != enableCompactMode) {
_compactMode.value = enableCompactMode;
}
}
}
@@ -31,6 +31,7 @@ class DatabaseLayoutBloc
@freezed
class DatabaseLayoutEvent with _$DatabaseLayoutEvent {
const factory DatabaseLayoutEvent.initial() = _Initial;
const factory DatabaseLayoutEvent.updateLayout(DatabaseLayoutPB layout) =
_UpdateLayout;
}
@@ -1,6 +1,7 @@
import 'package:appflowy/plugins/database/domain/database_view_service.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart';
import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/log.dart';
@@ -17,8 +18,17 @@ part 'tab_bar_bloc.freezed.dart';
class DatabaseTabBarBloc
extends Bloc<DatabaseTabBarEvent, DatabaseTabBarState> {
DatabaseTabBarBloc({required ViewPB view})
: super(DatabaseTabBarState.initial(view)) {
DatabaseTabBarBloc({
required ViewPB view,
required String compactModeId,
required bool enableCompactMode,
}) : super(
DatabaseTabBarState.initial(
view,
compactModeId,
enableCompactMode,
),
) {
on<DatabaseTabBarEvent>(
(event, emit) async {
await event.when(
@@ -154,10 +164,13 @@ class DatabaseTabBarBloc
) {
final tabBarControllerByViewId = {...state.tabBarControllerByViewId};
for (final view in newViews) {
final controller = DatabaseTabBarController(view: view);
controller.onViewUpdated = (newView) {
add(DatabaseTabBarEvent.viewDidUpdate(newView));
};
final controller = DatabaseTabBarController(
view: view,
compactModeId: state.compactModeId,
enableCompactMode: state.enableCompactMode,
)..onViewUpdated = (newView) {
add(DatabaseTabBarEvent.viewDidUpdate(newView));
};
tabBarControllerByViewId[view.id] = controller;
}
@@ -205,20 +218,27 @@ class DatabaseTabBarBloc
@freezed
class DatabaseTabBarEvent with _$DatabaseTabBarEvent {
const factory DatabaseTabBarEvent.initial() = _Initial;
const factory DatabaseTabBarEvent.didLoadChildViews(
List<ViewPB> childViews,
) = _DidLoadChildViews;
const factory DatabaseTabBarEvent.selectView(String viewId) = _DidSelectView;
const factory DatabaseTabBarEvent.createView(
DatabaseLayoutPB layout,
String? name,
) = _CreateView;
const factory DatabaseTabBarEvent.renameView(String viewId, String newName) =
_RenameView;
const factory DatabaseTabBarEvent.deleteView(String viewId) = _DeleteView;
const factory DatabaseTabBarEvent.didUpdateChildViews(
ChildViewUpdatePB updatePB,
) = _DidUpdateChildViews;
const factory DatabaseTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate;
}
@@ -227,19 +247,29 @@ class DatabaseTabBarState with _$DatabaseTabBarState {
const factory DatabaseTabBarState({
required ViewPB parentView,
required int selectedIndex,
required String compactModeId,
required bool enableCompactMode,
required List<DatabaseTabBar> tabBars,
required Map<String, DatabaseTabBarController> tabBarControllerByViewId,
}) = _DatabaseTabBarState;
factory DatabaseTabBarState.initial(ViewPB view) {
factory DatabaseTabBarState.initial(
ViewPB view,
String compactModeId,
bool enableCompactMode,
) {
final tabBar = DatabaseTabBar(view: view);
return DatabaseTabBarState(
parentView: view,
selectedIndex: 0,
compactModeId: compactModeId,
enableCompactMode: enableCompactMode,
tabBars: [tabBar],
tabBarControllerByViewId: {
view.id: DatabaseTabBarController(
view: view,
compactModeId: compactModeId,
enableCompactMode: enableCompactMode,
),
},
);
@@ -257,7 +287,9 @@ class DatabaseTabBar extends Equatable {
final DatabaseTabBarItemBuilder _builder;
String get viewId => view.id;
DatabaseTabBarItemBuilder get builder => _builder;
ViewLayoutPB get layout => view.layout;
@override
@@ -274,8 +306,18 @@ typedef OnViewChildViewChanged = void Function(
);
class DatabaseTabBarController {
DatabaseTabBarController({required this.view})
: controller = DatabaseController(view: view),
DatabaseTabBarController({
required this.view,
required String compactModeId,
required bool enableCompactMode,
}) : controller = DatabaseController(view: view)
..initCompactMode(enableCompactMode)
..addListener(
onCompactModeChanged: (v) async {
compactModeEventBus
.fire(CompactModeEvent(id: compactModeId, enable: v));
},
),
viewListener = ViewListener(viewId: view.id) {
viewListener.start(
onViewChildViewsUpdated: (update) => onViewChildViewChanged?.call(update),
@@ -285,6 +285,9 @@ class _BoardContentState extends State<_BoardContent> {
@override
Widget build(BuildContext context) {
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize?>()?.horizontalPadding ??
0.0;
return MultiBlocListener(
listeners: [
BlocListener<BoardBloc, BoardState>(
@@ -337,68 +340,85 @@ class _BoardContentState extends State<_BoardContent> {
focusScope: widget.focusScope,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
databaseController: databaseController,
groupData: groupData,
margin: config.groupHeaderPadding,
),
),
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<BoardBloc>()),
BlocProvider.value(value: context.read<BoardActionsCubit>()),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
),
cardBuilder: (context, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
child: ValueListenableBuilder(
valueListenable: databaseController.compactModeNotifier,
builder: (context, compactMode, _) {
return AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
shrinkWrap: widget.shrinkWrap,
controller: context.read<BoardBloc>().boardController,
groupConstraints:
BoxConstraints.tightFor(width: compactMode ? 196 : 256),
config: config,
leading: HiddenGroupsColumn(
shrinkWrap: widget.shrinkWrap,
margin: config.groupHeaderPadding +
EdgeInsets.only(
left: widget.shrinkWrap ? horizontalPadding : 0.0,
),
),
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
databaseController: databaseController,
groupData: groupData,
margin: config.groupHeaderPadding,
),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
BlocProvider(
create: (_) => ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
],
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
builder: (context, state) {
return IgnorePointer(
ignoring: state.isLocked,
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<BoardBloc>()),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
);
},
),
),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
),
cardBuilder: (context, column, columnItem) =>
MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
BlocProvider(
create: (_) => ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
],
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
builder: (context, state) {
return IgnorePointer(
ignoring: state.isLocked,
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
compactMode: compactMode,
),
);
},
),
),
);
},
),
),
),
@@ -560,6 +580,7 @@ class _BoardCard extends StatefulWidget {
required this.boardConfig,
required this.cellBuilder,
required this.notifier,
required this.compactMode,
});
final AppFlowyGroupData afGroupData;
@@ -567,6 +588,7 @@ class _BoardCard extends StatefulWidget {
final AppFlowyBoardConfig boardConfig;
final CardCellBuilder cellBuilder;
final BoardFocusScope notifier;
final bool compactMode;
@override
State<_BoardCard> createState() => _BoardCardState();
@@ -653,15 +675,21 @@ class _BoardCardState extends State<_BoardCard> {
return previousContainsFocus != currentContainsFocus;
},
builder: (context, focusedItems, child) => Container(
margin: widget.boardConfig.cardMargin,
decoration: _makeBoxDecoration(
context,
groupData.group.groupId,
widget.groupItem.id,
),
child: child,
),
builder: (context, focusedItems, child) {
final cardMargin = widget.boardConfig.cardMargin;
final margin = widget.compactMode
? cardMargin - EdgeInsets.symmetric(horizontal: 2)
: cardMargin;
return Container(
margin: margin,
decoration: _makeBoxDecoration(
context,
groupData.group.groupId,
widget.groupItem.id,
),
child: child,
);
},
child: RowCard(
fieldController: databaseController.fieldController,
rowMeta: rowMeta,
@@ -819,7 +847,7 @@ class _BoardTrailingState extends State<BoardTrailing> {
suffixIcon: Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8.0),
child: FlowyIconButton(
icon: const FlowySvg(FlowySvgs.close_filled_m),
icon: const FlowySvg(FlowySvgs.close_filled_s),
hoverColor: Colors.transparent,
onPressed: () => _textController.clear(),
),
@@ -2,10 +2,13 @@ import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
class BoardSettingBar extends StatelessWidget {
const BoardSettingBar({
@@ -30,6 +33,8 @@ class BoardSettingBar extends StatelessWidget {
if (value) {
return const SizedBox.shrink();
}
final isReference =
Provider.of<ReferenceState?>(context)?.isReference ?? false;
return SizedBox(
height: 20,
child: Row(
@@ -38,6 +43,10 @@ class BoardSettingBar extends StatelessWidget {
FilterButton(
toggleExtension: toggleExtension,
),
if (isReference) ...[
const HSpace(2),
ViewDatabaseButton(view: databaseController.view),
],
const HSpace(2),
SettingButton(
databaseController: databaseController,
@@ -1,8 +1,5 @@
import 'dart:io';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
@@ -16,20 +13,24 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HiddenGroupsColumn extends StatelessWidget {
const HiddenGroupsColumn({
super.key,
required this.margin,
required this.shrinkWrap,
});
final EdgeInsets margin;
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
@@ -85,11 +86,7 @@ class HiddenGroupsColumn extends StatelessWidget {
],
),
),
Expanded(
child: HiddenGroupList(
databaseController: databaseController,
),
),
_hiddenGroupList(databaseController),
],
),
),
@@ -98,6 +95,14 @@ class HiddenGroupsColumn extends StatelessWidget {
);
}
Widget _hiddenGroupList(DatabaseController databaseController) {
final hiddenGroupList = HiddenGroupList(
shrinkWrap: shrinkWrap,
databaseController: databaseController,
);
return shrinkWrap ? hiddenGroupList : Expanded(child: hiddenGroupList);
}
Widget _collapseExpandIcon(BuildContext context, bool isCollapsed) {
return FlowyTooltip(
message: isCollapsed
@@ -125,9 +130,11 @@ class HiddenGroupList extends StatelessWidget {
const HiddenGroupList({
super.key,
required this.databaseController,
required this.shrinkWrap,
});
final DatabaseController databaseController;
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
@@ -150,6 +157,7 @@ class HiddenGroupList extends StatelessWidget {
],
),
),
shrinkWrap: shrinkWrap,
buildDefaultDragHandles: false,
itemCount: state.hiddenGroups.length,
itemBuilder: (_, index) => Padding(
@@ -289,6 +289,7 @@ class _TitleTextCellSkin extends IEditableTextCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
TextCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
@@ -2,10 +2,13 @@ import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filter_button.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/view_database_button.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
class CalendarSettingBar extends StatelessWidget {
const CalendarSettingBar({
@@ -30,6 +33,8 @@ class CalendarSettingBar extends StatelessWidget {
if (value) {
return const SizedBox.shrink();
}
final isReference =
Provider.of<ReferenceState?>(context)?.isReference ?? false;
return SizedBox(
height: 20,
child: Row(
@@ -38,6 +43,10 @@ class CalendarSettingBar extends StatelessWidget {
FilterButton(
toggleExtension: toggleExtension,
),
if (isReference) ...[
const HSpace(2),
ViewDatabaseButton(view: databaseController.view),
],
const HSpace(2),
SettingButton(
databaseController: databaseController,
@@ -298,6 +298,7 @@ class _GridPageContentState extends State<GridPageContent> {
_GridHeader(
headerScrollController: headerScrollController,
editable: !context.read<ViewLockStatusBloc>().state.isLocked,
shrinkWrap: widget.shrinkWrap,
),
_GridRows(
viewId: widget.view.id,
@@ -313,10 +314,12 @@ class _GridHeader extends StatelessWidget {
const _GridHeader({
required this.headerScrollController,
required this.editable,
required this.shrinkWrap,
});
final ScrollController headerScrollController;
final bool editable;
final bool shrinkWrap;
@override
Widget build(BuildContext context) {
@@ -324,6 +327,7 @@ class _GridHeader extends StatelessWidget {
builder: (_, state) => GridHeaderSliverAdaptor(
viewId: state.viewId,
anchorScrollController: headerScrollController,
shrinkWrap: shrinkWrap,
),
);
@@ -416,12 +420,13 @@ class _GridRowsState extends State<_GridRows> {
constraints: BoxConstraints(
maxWidth: GridLayout.headerWidth(
context
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding,
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding *
3,
context.read<GridBloc>().state.fields,
),
),
child: _renderList(context),
child: _shrinkWrapRenderList(context),
),
),
);
@@ -452,9 +457,31 @@ class _GridRowsState extends State<_GridRows> {
return Flexible(child: child);
}
Widget _shrinkWrapRenderList(BuildContext context) {
final state = context.read<GridBloc>().state;
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize?>()?.horizontalPadding ??
0.0;
return ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
children: [
widget.shrinkWrap
? _reorderableListView(state)
: Expanded(child: _reorderableListView(state)),
if (showFloatingCalculations && !widget.shrinkWrap) ...[
_PositionedCalculationsRow(
viewId: widget.viewId,
isAtBottom: isAtBottom,
),
],
],
);
}
Widget _renderList(BuildContext context) {
final state = context.read<GridBloc>().state;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1,5 +1,6 @@
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pbenum.dart';
import 'sizes.dart';
class GridLayout {
@@ -5,16 +5,26 @@ class GridSize {
static double scale = 1;
static double get scrollBarSize => 8 * scale;
static double get headerHeight => 36 * scale;
static double get buttonHeight => 38 * scale;
static double get footerHeight => 36 * scale;
static double get horizontalHeaderPadding =>
UniversalPlatform.isDesktop ? 40 * scale : 16 * scale;
static double get cellHPadding => 10 * scale;
static double get cellVPadding => 10 * scale;
static double get cellVPadding => 8 * scale;
static double get popoverItemHeight => 26 * scale;
static double get typeOptionSeparatorHeight => 4 * scale;
static double get newPropertyButtonWidth => 140 * scale;
static double get mobileNewPropertyButtonWidth => 200 * scale;
static EdgeInsets get cellContentInsets => EdgeInsets.symmetric(
@@ -22,10 +32,8 @@ class GridSize {
vertical: GridSize.cellVPadding,
);
static EdgeInsets get fieldContentInsets => EdgeInsets.symmetric(
horizontal: GridSize.cellHPadding,
vertical: GridSize.cellVPadding,
);
static EdgeInsets get compactCellContentInsets =>
cellContentInsets - EdgeInsets.symmetric(vertical: 2);
static EdgeInsets get typeOptionContentInsets => const EdgeInsets.all(4);
@@ -21,11 +21,13 @@ class GridHeaderSliverAdaptor extends StatefulWidget {
const GridHeaderSliverAdaptor({
super.key,
required this.viewId,
required this.shrinkWrap,
required this.anchorScrollController,
});
final String viewId;
final ScrollController anchorScrollController;
final bool shrinkWrap;
@override
State<GridHeaderSliverAdaptor> createState() =>
@@ -37,6 +39,9 @@ class _GridHeaderSliverAdaptorState extends State<GridHeaderSliverAdaptor> {
Widget build(BuildContext context) {
final fieldController =
context.read<GridBloc>().databaseController.fieldController;
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize?>()?.horizontalPadding ??
0.0;
return BlocProvider(
create: (context) {
return GridHeaderBloc(
@@ -47,9 +52,14 @@ class _GridHeaderSliverAdaptorState extends State<GridHeaderSliverAdaptor> {
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: widget.anchorScrollController,
child: _GridHeader(
viewId: widget.viewId,
fieldController: fieldController,
child: Padding(
padding: widget.shrinkWrap
? EdgeInsets.symmetric(horizontal: horizontalPadding)
: EdgeInsets.zero,
child: _GridHeader(
viewId: widget.viewId,
fieldController: fieldController,
),
),
),
);
@@ -308,14 +308,20 @@ class RowContent extends StatelessWidget {
Widget _finalCellDecoration(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Container(
width: GridSize.newPropertyButtonWidth,
constraints: const BoxConstraints(minHeight: 36),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: AFThemeExtension.of(context).borderColor),
),
),
child: ValueListenableBuilder(
valueListenable: cellBuilder.databaseController.compactModeNotifier,
builder: (context, compactMode, _) {
return Container(
width: GridSize.newPropertyButtonWidth,
constraints: BoxConstraints(minHeight: compactMode ? 32 : 36),
decoration: BoxDecoration(
border: Border(
bottom:
BorderSide(color: AFThemeExtension.of(context).borderColor),
),
),
);
},
),
);
}
@@ -1,14 +1,13 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../layout/sizes.dart';
import '../filter/create_filter_list.dart';
class FilterButton extends StatefulWidget {
@@ -30,27 +29,25 @@ class _FilterButtonState extends State<FilterButton> {
Widget build(BuildContext context) {
return BlocBuilder<FilterEditorBloc, FilterEditorState>(
builder: (context, state) {
final textColor = state.filters.isEmpty
? Theme.of(context).hintColor
: Theme.of(context).colorScheme.primary;
return _wrapPopover(
FlowyTextButton(
LocaleKeys.grid_settings_filter.tr(),
fontColor: textColor,
fontSize: FontSizes.s12,
fillColor: Colors.transparent,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: GridSize.toolbarSettingButtonInsets,
radius: Corners.s4Border,
onPressed: () {
final bloc = context.read<FilterEditorBloc>();
if (bloc.state.filters.isEmpty) {
_popoverController.show();
} else {
widget.toggleExtension.toggle();
}
},
MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowyIconButton(
tooltipText: LocaleKeys.grid_settings_filter.tr(),
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
icon: const FlowySvg(FlowySvgs.database_filter_s),
onPressed: () {
final bloc = context.read<FilterEditorBloc>();
if (bloc.state.filters.isEmpty) {
_popoverController.show();
} else {
widget.toggleExtension.toggle();
}
},
),
),
);
},
@@ -3,12 +3,15 @@ import 'package:appflowy/plugins/database/grid/application/filter/filter_editor_
import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'filter_button.dart';
import 'sort_button.dart';
import 'view_database_button.dart';
class GridSettingBar extends StatelessWidget {
const GridSettingBar({
@@ -43,18 +46,22 @@ class GridSettingBar extends StatelessWidget {
if (isLoading) {
return const SizedBox.shrink();
}
final isReference =
Provider.of<ReferenceState?>(context)?.isReference ?? false;
return SizedBox(
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilterButton(toggleExtension: toggleExtension),
const HSpace(6),
const HSpace(2),
SortButton(toggleExtension: toggleExtension),
const HSpace(6),
SettingButton(
databaseController: controller,
),
if (isReference) ...[
const HSpace(2),
ViewDatabaseButton(view: controller.view),
],
const HSpace(2),
SettingButton(databaseController: controller),
],
),
);
@@ -1,13 +1,12 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/sort/sort_editor_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import '../sort/create_sort_list.dart';
@@ -27,26 +26,24 @@ class _SortButtonState extends State<SortButton> {
Widget build(BuildContext context) {
return BlocBuilder<SortEditorBloc, SortEditorState>(
builder: (context, state) {
final textColor = state.sorts.isEmpty
? Theme.of(context).hintColor
: Theme.of(context).colorScheme.primary;
return wrapPopover(
FlowyTextButton(
LocaleKeys.grid_settings_sort.tr(),
fontColor: textColor,
fontSize: FontSizes.s12,
fillColor: Colors.transparent,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: GridSize.toolbarSettingButtonInsets,
radius: Corners.s4Border,
onPressed: () {
if (state.sorts.isEmpty) {
_popoverController.show();
} else {
widget.toggleExtension.toggle();
}
},
MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowyIconButton(
tooltipText: LocaleKeys.grid_settings_sort.tr(),
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
icon: const FlowySvg(FlowySvgs.database_sort_s),
onPressed: () {
if (state.sorts.isEmpty) {
_popoverController.show();
} else {
widget.toggleExtension.toggle();
}
},
),
),
);
},
@@ -0,0 +1,37 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
class ViewDatabaseButton extends StatelessWidget {
const ViewDatabaseButton({super.key, required this.view});
final ViewPB view;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowyIconButton(
tooltipText: LocaleKeys.grid_rowPage_openAsFullPage.tr(),
width: 24,
height: 24,
iconPadding: const EdgeInsets.all(3),
icon: const FlowySvg(FlowySvgs.database_fullscreen_s),
onPressed: () {
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: view.plugin(),
view: view,
),
);
},
),
);
}
}
@@ -1,8 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -16,6 +16,7 @@ import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'tab_bar_add_button.dart';
@@ -26,12 +27,8 @@ class TabBarHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return SizedBox(
height: 35,
padding: EdgeInsets.symmetric(
horizontal:
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding,
),
child: Stack(
children: [
Positioned(
@@ -332,7 +329,24 @@ class _TabBarItemButtonState extends State<TabBarItemButton> {
enableColor: false,
);
}
return Opacity(opacity: 0.6, child: icon);
final isReference =
Provider.of<ReferenceState?>(context)?.isReference ?? false;
final iconWidget = Opacity(opacity: 0.6, child: icon);
return isReference
? Stack(
children: [
iconWidget,
const Positioned(
right: 0,
bottom: 0,
child: FlowySvg(
FlowySvgs.referenced_page_s,
blendMode: BlendMode.dstIn,
),
),
],
)
: iconWidget;
}
}
@@ -1,9 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/document/presentation/compact_mode_event.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/database/database_view_block_component.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
@@ -12,7 +20,9 @@ import 'package:appflowy/workspace/presentation/widgets/favorite_button.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
@@ -55,31 +65,84 @@ abstract class DatabaseTabBarItemBuilder {
void dispose() {}
}
class DatabaseTabBarView extends StatelessWidget {
class DatabaseTabBarView extends StatefulWidget {
const DatabaseTabBarView({
super.key,
required this.view,
required this.shrinkWrap,
required this.showActions,
this.initialRowId,
this.actionBuilder,
this.node,
});
final ViewPB view;
final bool shrinkWrap;
final BlockComponentActionBuilder? actionBuilder;
final bool showActions;
final Node? node;
/// Used to open a Row on plugin load
///
final String? initialRowId;
@override
State<DatabaseTabBarView> createState() => _DatabaseTabBarViewState();
}
class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
bool enableCompactMode = false;
bool initialed = false;
StreamSubscription<CompactModeEvent>? compactModeSubscription;
String get compactModeId => widget.node?.id ?? widget.view.id;
@override
void initState() {
super.initState();
if (widget.node != null) {
enableCompactMode =
widget.node!.attributes[DatabaseBlockKeys.enableCompactMode] ?? false;
setState(() {
initialed = true;
});
} else {
fetchLocalCompactMode(compactModeId).then((v) {
if (mounted) {
setState(() {
enableCompactMode = v;
initialed = true;
});
}
});
compactModeSubscription =
compactModeEventBus.on<CompactModeEvent>().listen((event) {
if (event.id != widget.view.id) return;
updateLocalCompactMode(event.enable);
});
}
}
@override
void dispose() {
super.dispose();
compactModeSubscription?.cancel();
}
@override
Widget build(BuildContext context) {
if (!initialed) return Center(child: CircularProgressIndicator());
return MultiBlocProvider(
providers: [
BlocProvider<DatabaseTabBarBloc>(
create: (_) => DatabaseTabBarBloc(view: view)
..add(const DatabaseTabBarEvent.initial()),
create: (_) => DatabaseTabBarBloc(
view: widget.view,
compactModeId: compactModeId,
enableCompactMode: enableCompactMode,
)..add(const DatabaseTabBarEvent.initial()),
),
BlocProvider<ViewBloc>(
create: (_) => ViewBloc(view: view)
create: (_) => ViewBloc(view: widget.view)
..add(
const ViewEvent.initial(),
),
@@ -88,9 +151,15 @@ class DatabaseTabBarView extends StatelessWidget {
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (innerContext, state) {
final layout = state.tabBars[state.selectedIndex].layout;
final isCalendar = layout == ViewLayoutPB.Calendar;
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding;
final showActionWrapper = widget.showActions &&
widget.actionBuilder != null &&
widget.node != null;
final Widget child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (UniversalPlatform.isMobile) const VSpace(12),
ValueListenableBuilder<bool>(
@@ -113,13 +182,39 @@ class DatabaseTabBarView extends StatelessWidget {
);
}
if (showActionWrapper) {
child = BlockComponentActionWrapper(
node: widget.node!,
actionBuilder: widget.actionBuilder!,
child: Padding(
padding: EdgeInsets.only(right: horizontalPadding),
child: child,
),
);
}
if (UniversalPlatform.isDesktop) {
child = Container(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
),
child: child,
);
}
return child;
},
),
pageSettingBarExtensionFromState(context, state),
wrapContent(
layout: layout,
child: pageContentFromState(context, state),
child: Padding(
padding:
(isCalendar && widget.shrinkWrap || showActionWrapper)
? EdgeInsets.only(left: 42 - horizontalPadding)
: EdgeInsets.zero,
child: pageContentFromState(context, state),
),
),
],
);
@@ -130,8 +225,44 @@ class DatabaseTabBarView extends StatelessWidget {
);
}
Future<bool> fetchLocalCompactMode(String compactModeId) async {
Set<String> compactModeIds = {};
try {
final localIds = await getIt<KeyValueStorage>().get(
KVKeys.compactModeIds,
);
final List<dynamic> decodedList = jsonDecode(localIds ?? '');
compactModeIds = Set.from(decodedList.map((item) => item as String));
} catch (e) {
Log.warn('fetch local compact mode from id :$compactModeId failed', e);
}
return compactModeIds.contains(compactModeId);
}
Future<void> updateLocalCompactMode(bool enableCompactMode) async {
Set<String> compactModeIds = {};
try {
final localIds = await getIt<KeyValueStorage>().get(
KVKeys.compactModeIds,
);
final List<dynamic> decodedList = jsonDecode(localIds ?? '');
compactModeIds = Set.from(decodedList.map((item) => item as String));
} catch (e) {
Log.warn('get compact mode ids failed', e);
}
if (enableCompactMode) {
compactModeIds.add(compactModeId);
} else {
compactModeIds.remove(compactModeId);
}
await getIt<KeyValueStorage>().set(
KVKeys.compactModeIds,
jsonEncode(compactModeIds.toList()),
);
}
Widget wrapContent({required ViewLayoutPB layout, required Widget child}) {
if (shrinkWrap) {
if (widget.shrinkWrap) {
if (layout.shrinkWrappable) {
return child;
}
@@ -153,8 +284,8 @@ class DatabaseTabBarView extends StatelessWidget {
context,
tab.view,
controller,
shrinkWrap,
initialRowId,
widget.shrinkWrap,
widget.initialRowId,
);
}
@@ -226,6 +357,9 @@ class DatabaseTabBarViewPlugin extends Plugin {
}
const kDatabasePluginWidgetBuilderHorizontalPadding = 'horizontal_padding';
const kDatabasePluginWidgetBuilderShowActions = 'show_actions';
const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder';
const kDatabasePluginWidgetBuilderNode = 'node';
class DatabasePluginWidgetBuilderSize {
const DatabasePluginWidgetBuilderSize({required this.horizontalPadding});
@@ -274,6 +408,11 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
final horizontalPadding =
data?[kDatabasePluginWidgetBuilderHorizontalPadding] as double? ??
GridSize.horizontalHeaderPadding + 40;
final BlockComponentActionBuilder? actionBuilder =
data?[kDatabasePluginWidgetBuilderActionBuilder];
final bool showActions =
data?[kDatabasePluginWidgetBuilderShowActions] ?? false;
final Node? node = data?[kDatabasePluginWidgetBuilderNode];
return Provider(
create: (context) => DatabasePluginWidgetBuilderSize(
@@ -284,6 +423,9 @@ class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
view: notifier.view,
shrinkWrap: shrinkWrap,
initialRowId: initialRowId,
actionBuilder: actionBuilder,
showActions: showActions,
node: node,
),
);
}
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/database/card/card.dart';
@@ -16,12 +14,12 @@ import 'package:collection/collection.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../cell/card_cell_builder.dart';
import '../cell/card_cell_skeleton/card_cell.dart';
import 'card_bloc.dart';
import 'container/accessory.dart';
import 'container/card_container.dart';
@@ -462,7 +460,7 @@ class RowCardStyleConfiguration {
const RowCardStyleConfiguration({
required this.cellStyleMap,
this.showAccessory = true,
this.cardPadding = const EdgeInsets.all(8),
this.cardPadding = const EdgeInsets.all(4),
this.hoverStyle,
});
@@ -40,7 +40,7 @@ class RowCardContainer extends StatelessWidget {
}
},
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 42),
constraints: const BoxConstraints(minHeight: 36),
child: _CardEnterRegion(
shouldBuildAccessory: shouldBuildAccessory,
accessories: accessories,
@@ -77,8 +77,8 @@ class _CardEnterRegion extends StatelessWidget {
child,
if (onEnter && shouldBuildAccessory)
Positioned(
top: 10.0,
right: 10.0,
top: 7.0,
right: 7.0,
child: CardAccessoryContainer(
accessories: accessories,
onTapAccessory: onTapAccessory,
@@ -1,7 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -12,22 +12,31 @@ class DesktopGridCheckboxCellSkin extends IEditableCheckboxCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
CheckboxCellBloc bloc,
CheckboxCellState state,
) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () => bloc.add(const CheckboxCellEvent.select()),
icon: FlowySvg(
state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
size: const Size.square(20),
),
width: 20,
),
return ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
final padding = compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets;
return Container(
alignment: AlignmentDirectional.centerStart,
padding: padding,
child: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () => bloc.add(const CheckboxCellEvent.select()),
icon: FlowySvg(
state.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
size: const Size.square(20),
),
width: 20,
),
);
},
);
}
}
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/checklist.dart';
@@ -15,6 +14,7 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
ChecklistCellBloc bloc,
PopoverController popoverController,
) {
@@ -39,15 +39,24 @@ class DesktopGridChecklistCellSkin extends IEditableChecklistCellSkin {
onClose: () => cellContainerNotifier.isFocus = false,
child: BlocBuilder<ChecklistCellBloc, ChecklistCellState>(
builder: (context, state) {
return Container(
alignment: AlignmentDirectional.centerStart,
padding: GridSize.cellContentInsets,
child: state.tasks.isEmpty
? const SizedBox.shrink()
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
return ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
final padding = compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets;
return Container(
alignment: AlignmentDirectional.centerStart,
padding: padding,
child: state.tasks.isEmpty
? const SizedBox.shrink()
: ChecklistProgressBar(
tasks: state.tasks,
percent: state.percent,
),
);
},
);
},
),
@@ -1,9 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/date_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/date_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
@@ -15,6 +15,7 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
DateCellBloc bloc,
DateCellState state,
PopoverController popoverController,
@@ -28,11 +29,11 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin {
child: Align(
alignment: AlignmentDirectional.centerStart,
child: state.fieldInfo.wrapCellContent ?? false
? _buildCellContent(state)
? _buildCellContent(state, compactModeNotifier)
: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
scrollDirection: Axis.horizontal,
child: _buildCellContent(state),
child: _buildCellContent(state, compactModeNotifier),
),
),
popupBuilder: (BuildContext popoverContent) {
@@ -47,33 +48,44 @@ class DesktopGridDateCellSkin extends IEditableDateCellSkin {
);
}
Widget _buildCellContent(DateCellState state) {
Widget _buildCellContent(
DateCellState state,
ValueNotifier<bool> compactModeNotifier,
) {
final wrap = state.fieldInfo.wrapCellContent ?? false;
final dateStr = getDateCellStrFromCellData(
state.fieldInfo,
state.cellData,
);
return Padding(
padding: GridSize.cellContentInsets,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText(
dateStr,
overflow: wrap ? null : TextOverflow.ellipsis,
maxLines: wrap ? null : 1,
),
return ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
final padding = compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets;
return Padding(
padding: padding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FlowyText(
dateStr,
overflow: wrap ? null : TextOverflow.ellipsis,
maxLines: wrap ? null : 1,
),
),
if (state.cellData.reminderId.isNotEmpty) ...[
const HSpace(4),
FlowyTooltip(
message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(),
child: const FlowySvg(FlowySvgs.clock_alarm_s),
),
],
],
),
if (state.cellData.reminderId.isNotEmpty) ...[
const HSpace(4),
FlowyTooltip(
message: LocaleKeys.grid_field_reminderOnDateTooltip.tr(),
child: const FlowySvg(FlowySvgs.clock_alarm_s),
),
],
],
),
);
},
);
}
}
@@ -72,7 +72,7 @@ class GridMediaCellSkin extends IEditableMediaCellSkin {
if (!isMobile && wrapContent) {
return Padding(
padding: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: SizedBox(
width: double.infinity,
child: Wrap(
@@ -233,7 +233,7 @@ class _FilePreviewRender extends StatelessWidget {
height: 28,
width: 28,
clipBehavior: Clip.antiAlias,
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).greyHover,
borderRadius: BorderRadius.circular(4),
@@ -1,6 +1,6 @@
import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/number_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -11,27 +11,37 @@ class DesktopGridNumberCellSkin extends IEditableNumberCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
NumberCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: context.watch<NumberCellBloc>().state.wrap ? null : 1,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
return ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
final padding = compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets;
return TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: context.watch<NumberCellBloc>().state.wrap ? null : 1,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: padding,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
);
},
);
}
}
@@ -1,8 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/relation_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/relation_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -16,10 +17,12 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
RelationCellBloc bloc,
RelationCellState state,
PopoverController popoverController,
) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
return AppFlowyPopover(
controller: popoverController,
direction: PopoverDirection.bottomWithLeftAligned,
@@ -27,16 +30,24 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
margin: EdgeInsets.zero,
onClose: () => cellContainerNotifier.isFocus = false,
popupBuilder: (context) {
return BlocProvider.value(
value: bloc,
return MultiBlocProvider(
providers: [
BlocProvider.value(value: userWorkspaceBloc),
BlocProvider.value(value: bloc),
],
child: const RelationCellEditor(),
);
},
child: Align(
alignment: AlignmentDirectional.centerStart,
child: state.wrap
? _buildWrapRows(context, state.rows)
: _buildNoWrapRows(context, state.rows),
child: ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
return state.wrap
? _buildWrapRows(context, state.rows, compactMode)
: _buildNoWrapRows(context, state.rows, compactMode);
},
),
),
);
}
@@ -44,9 +55,12 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
Widget _buildWrapRows(
BuildContext context,
List<RelatedRowDataPB> rows,
bool compactMode,
) {
return Padding(
padding: GridSize.cellContentInsets,
padding: compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets,
child: Wrap(
runSpacing: 4,
spacing: 4.0,
@@ -68,6 +82,7 @@ class DesktopGridRelationCellSkin extends IEditableRelationCellSkin {
Widget _buildNoWrapRows(
BuildContext context,
List<RelatedRowDataPB> rows,
bool compactMode,
) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
@@ -15,6 +15,7 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
SelectOptionCellBloc bloc,
PopoverController popoverController,
) {
@@ -35,63 +36,92 @@ class DesktopGridSelectOptionCellSkin extends IEditableSelectOptionCellSkin {
return Align(
alignment: AlignmentDirectional.centerStart,
child: state.wrap
? _buildWrapOptions(context, state.selectedOptions)
: _buildNoWrapOptions(context, state.selectedOptions),
? _buildWrapOptions(
context,
state.selectedOptions,
compactModeNotifier,
)
: _buildNoWrapOptions(
context,
state.selectedOptions,
compactModeNotifier,
),
);
},
),
);
}
Widget _buildWrapOptions(BuildContext context, List<SelectOptionPB> options) {
return Padding(
padding: GridSize.cellContentInsets,
child: Wrap(
runSpacing: 4,
children: options.map(
(option) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
),
);
},
).toList(),
),
Widget _buildWrapOptions(
BuildContext context,
List<SelectOptionPB> options,
ValueNotifier<bool> compactModeNotifier,
) {
return ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
final padding = compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets;
return Padding(
padding: padding,
child: Wrap(
runSpacing: 4,
children: options.map(
(option) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: SelectOptionTag(
option: option,
padding: EdgeInsets.symmetric(
vertical: compactMode ? 2 : 4,
horizontal: 8,
),
),
);
},
).toList(),
),
);
},
);
}
Widget _buildNoWrapOptions(
BuildContext context,
List<SelectOptionPB> options,
ValueNotifier<bool> compactModeNotifier,
) {
return SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
scrollDirection: Axis.horizontal,
child: Padding(
padding: GridSize.cellContentInsets,
child: Row(
mainAxisSize: MainAxisSize.min,
children: options.map(
(option) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 1,
horizontal: 8,
),
),
);
},
).toList(),
),
child: ValueListenableBuilder(
valueListenable: compactModeNotifier,
builder: (context, compactMode, _) {
final padding = compactMode
? GridSize.compactCellContentInsets
: GridSize.cellContentInsets;
return Padding(
padding: padding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: options.map(
(option) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: SelectOptionTag(
option: option,
padding: const EdgeInsets.symmetric(
vertical: 1,
horizontal: 8,
),
),
);
},
).toList(),
),
);
},
),
);
}
Loaded 100 of 527 files, more files were not shown because too many files have changed in this diff. Show more