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
Morn fd12d3a0b0 chore: update CHANGELOG.md (#7397) 2025-02-18 16:48:33 +08:00
Lucas f25821e84d chore: update auto updater feed url (#7396) 2025-02-18 16:14:13 +08:00
Morn c0aa0e0509 fix: some launch review issues (#7379)
* fix: some launch review issues

* fix: some launch review issues

* fix: some launch review issues

* feat: add change button for icon uploader
2025-02-17 16:08:51 +08:00
Lucas 55fbb7522b feat: switch ai mode on mobile (#7391)
* fix: the default page name should be empty when creating

* feat: switch ai model on mobile
2025-02-17 16:08:38 +08:00
Li, John Gen 15b4d496fd chore: update translations (#7387)
* 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-17 15:23:11 +08:00
Lucas ad54ad0614 fix: the default page name should be empty when creating (#7389) 2025-02-17 15:13:25 +08:00
Lucas b18bbd0e82 fix: auto updater issue on Windows (#7385) 2025-02-17 09:56:30 +08:00
Lucas e028e45e93 fix: lock page issues (#7380)
* fix: unable to click the swith to lock/unlock page

* fix: add divider above delete button on mobile

* fix: enable lock/unlock page by tapping the lock icon

* chore: update translations

* fix: hide cursor when the page is locked

* fix: the inline databaes still can be edited if the document is locked

* fix: disable auto update checker

* chore: change my account to account & app
2025-02-14 17:02:25 +08:00
Lucas 0e7ac85f90 chore: add ai images label (#7376) 2025-02-13 14:56:16 +08:00
Lucas 8bb2541862 chore: upgrade version (#7374) 2025-02-13 13:57:37 +08:00
Lucas 133e61befd fix: clear background color of header row & header column (#7373) 2025-02-13 13:21:02 +08:00
Morn 9e98680861 feat: support slash menu on mobile (#7368)
* feat: support slash menu on mobile

* feat: support at menu on mobile

* feat: support plus menu on mobile
2025-02-13 12:45:56 +08:00
Khor Shu Heng b75fd673cd chore: update collab version (#7372) 2025-02-13 12:45:45 +08:00
Lucas 4a7e20b3a5 fix: save image should not copy the image (#7370)
* fix: save image should not copy the image

* fix: unable to scroll the table in AI Chat on mobile
2025-02-12 17:35:59 +08:00
MornandLucas.Xu bbe746c564 feat: support upload svg as icon (#7270)
* feat: support upload svg as icon

* feat: support upload icon by pasting a link

* feat: delete remote images when remove custon icons

* chore: add testing for pasting image link as custon icon

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-02-12 15:08:50 +08:00
Reagan lee 189faa4def chore: update translations (#7351)
* 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-12 15:08:01 +08:00
Lucas c1a8d89938 feat: lock page on mobile (#7366)
* feat: support lock button in view more actions

* feat: add lock page on mobile

* feat: disable actions in locked page

* feat: disable more actions in locked page

* feat: support locked grid on mobile

* feat: support locked board/calendar on mobile

* fix: exclude lock page button from AI Chat
2025-02-12 15:07:21 +08:00
Lucas 71ce9affbe feat: lock page (#7353)
* feat: lock page

* feat: add pageLockStatus bloc

* feat: add lock status and unlock status in title bar

* feat: add loading lock status

* feat: disable moveTo, delete, rename, updateIcon operations if the page is locked

* fix: lock toast issue

* feat: support locked database

* feat: support locked grid

* feat: support locked title

* feat: support locked board

* feat: support locked calendar
2025-02-12 09:49:36 +08:00
Morn 552dba5abe fix: support exporting more content to markdown (#7333)
* fix: support exporting to markdown with multiple images

* fix: support exporting to markdown with database

* fix: support exporting to markdown with date or reminder

* fix: support exporting to markdown with subpage and page reference

* chore: add some testing for markdown parser

* chore: add testing for exporting markdown with databse as csv
2025-02-11 21:46:02 +08:00
Richard Shiue 04e3246976 chore: rename predefined format enum variant (#7359) 2025-02-11 16:40:39 +08:00
Morn 5d73c3d194 fix: gallery not rendering in row page (#7349) 2025-02-11 14:03:49 +08:00
Richard Shiue 12d9a98831 chore: disable impeller on android (#7355) 2025-02-11 14:03:29 +08:00
Lucas 8f646a2843 feat: integrate version checker for Linux (#7346)
* feat: integrate auto_updater in macOS

* chore: update translations

* chore: bump auto_updater version

* feat: exclude linux platform in auto update task

* feat: support auto_updater on Linux

* chore: combine version checker and auto updater into same class
2025-02-10 15:07:53 +08:00
Lucas f53e9d6549 feat: integrate auto_updater for macOS (#7328)
* feat: integrate auto_updater in macOS

* chore: update translations

* chore: bump auto_updater version

* feat: exclude linux platform in auto update task

* chore: disable auto updater

* fix: integration tests

* fix: integration tests
2025-02-10 09:20:24 +08:00
Richard Shiue fc9c152553 fix(flutter_desktop): selection in AI chat going missing while scrolling (#7281) 2025-02-07 18:44:55 +08:00
Lucas 00cdee831d chore: upgrade to Flutter 3.27.4 (#7230) 2025-02-07 18:17:46 +08:00
Morn 17ae05a623 fix: pasting a link on iOS results in incorrect behavior (#7326) 2025-02-07 14:49:50 +08:00
hasanbeder 8b1a03713b feat(i18n): Add Turkish (tr-TR) language translation (#7329)
- Complete Turkish language translation for AppFlowy
- Covers all UI elements and user-facing strings
- Improves localization support for Turkish users
2025-02-07 09:58:15 +08:00
Richard Shiue e4b57033b4 fix: improve calendar event button icon color and add confirm dialog (#7330)
* fix: improve calendar event button icon color and add confirm dialog

* test: update test
2025-02-06 22:46:22 +08:00
Richard ShiueandNathan 62a6fb8913 chore: optional response format (#7204)
* chore: optional response format

* chore: bump client api

* chore: code cleanup

* chore: bump client api

---------

Co-authored-by: Nathan <nathan@appflowy.io>
2025-02-06 18:10:23 +08:00
FakhriAzzouz 0d89e22ed2 chore(i18n): update ar-SA translations (#7312)
* 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-06 13:54:16 +08:00
ArtemisOne ff2aae213c chore(i18n): update es-VE and ga-IE translations (#7320)
* 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-06 13:53:24 +08:00
Richard Shiue 43e64d8219 test: fix image integration test (#7323) 2025-02-05 19:45:21 +08:00
Richard Shiue 6823fe5d24 fix: row document images (#7322)
* fix: row document images

* fix: calendar

* chore: fallback to documentbloc
2025-02-05 16:40:03 +08:00
Lucas f683085618 fix: unable to upload file on Android devices (#7314) 2025-02-04 20:57:06 +08:00
Nathan.fooo 71a22dc466 chore: fix ai page user profile refresh (#7317) 2025-02-04 20:29:56 +08:00
Lucas eb508a3ec9 fix: editor stuck on image loading loop when uploading image in row document (#7313)
* fix: editor stuck on image loading loop when uploading image in row document

* test: editor stuck on image loading loop when uploading image in row document
2025-02-04 14:05:57 +08:00
Nathan.fooo aacd09d8e2 chore: Support new error code (#7311)
* chore: fetch model list

* chore: suppor new error code
2025-02-03 20:52:08 +08:00
Peter Jose 25a27dfa81 chore(i18n): update id-ID translations (#7290)
- Translate 'themeMode' label
- Adjust text 'fontFamily'
- Update 'layoutDirection' translation
- Update 'textDirection' translation
2025-02-03 10:41:05 +08:00
Mohammad Mahdi Momeni 36349778e3 chore(i18n): update fa translations (#7292) 2025-02-03 10:05:34 +08:00
Nathan.fooo 9271d42db5 chore: fetch model list (#7306)
* chore: fetch model list
2025-02-01 23:48:32 +08:00
Richard Shiue 90d6e98b51 fix(flutter_mobile): drop focus on tap outside (#7274) 2025-01-24 09:23:52 +08:00
Lucas dd6b285cd3 chore: update CHANGELOG.md (#7276) 2025-01-23 17:55:12 +08:00
FakhriAzzouz 8b0d5d76a2 New Arabic Translations (#7260)
* 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-01-23 14:02:32 +08:00
Morn 85b69bde15 chore: cancel button not visible in [Reset to Default] for shortcuts(#7171) (#7257) 2025-01-23 13:58:48 +08:00
Morn dd812d0501 chore: rename [font scale factor] to [scale factor] (#7267) 2025-01-23 13:58:09 +08:00
Richard Shiue a79f825ff1 chore: restrict uploadable image types (#7269)
* chore: restrict image format

* chore: code cleanup
2025-01-23 10:51:15 +08:00
Lucas 862e5629e3 fix: v0.8.2 launch review issues (#7266)
* fix: unable to copy table from Apple Notes

* fix: disable pasting a table into another table

* fix: ignore the delete shortcut if the next node is a simple table node
2025-01-22 22:46:03 +08:00
Richard Shiue c5a91e10df chore: support pasting image links that have no file extension (#7262)
* test: speed up copypasta tests

* chore: strip away markdown syntax if message is image only

* chore: paste image urls with no file extension

* test: add integration test

* test: group tests

* chore: apply code suggestions to 3 files
2025-01-22 22:13:18 +08:00
Lucas 0b0e10baa8 feat: turn -> into to → in document (#7256)
* chore: bump version 0.8.2

* feat: turn -> into to → in document

* Revert "chore: bump version 0.8.2"

This reverts commit 45efd4d7d7.

* test: add shortcut tests
2025-01-22 15:05:22 +08:00
Nathan ec18a3c443 chore: update docs 2025-01-22 09:47:06 +08:00
Nathan.fooo a0867ed688 chore: return ai image error (#7263) 2025-01-22 09:42:24 +08:00
Nathan.fooo cdf8e68ff2 Update plan desc (#7258)
* chore: update plan desc

* chore: update plan desc
2025-01-22 02:02:30 +08:00
Richard Shiue af92303e38 fix: launch review issues 0.8.2 (#7261) 2025-01-21 22:17:53 +08:00
Lucas 504041d75a chore: bump version 0.8.2 (#7255) 2025-01-21 13:31:40 +08:00
Lucas 06ab965413 chore: only enable document integrity check when enableDocumentInternalLog is on (#7251)
* chore: only enable document integrity check when enableDocumentInternalLog is on

* feat: copy divider and table from ChatGPT

* test: add copy from ChatGPT test

* feat: support copying link from keyboard clipboard
2025-01-21 13:29:31 +08:00
Morn 42bd4884fd feat: support custom image icon (#7236)
* feat: supporting upload custom image as icon

* feat: support custom image icon on mobile

* chore: clean code
2025-01-21 11:58:45 +08:00
Lucas 0c057243bc chore: bump client api (#7249)
* chore: bump client api

* chore: set default publish info to true
2025-01-21 09:18:25 +08:00
Lucas 8fdc6e9638 feat: support pasting table from notion, google docs and google sheet (#7247)
* feat: support pasting table from Notion

* test: add google docs / googles sheets table test

* fix: google docs test

* fix: paste table from notion test
2025-01-21 09:12:57 +08:00
Morn 187f7409ce feat: enable customizing icons for database views (#7187)
* feat: enable set icon for databse tab

* chore: resolve conflicts

* feat: enable changing dabtabse icon on mobile
2025-01-20 17:55:01 +08:00
Morn e3ce6e8b4b fix: use WindowSizeManager to zoom on mobile (#7215) 2025-01-20 17:54:26 +08:00
Richard Shiue cfe481759f chore: bump table column width when saving chat message to page (#7243)
* chore: bump table column width when saving chat message to page

* test: add unit test
2025-01-20 13:28:02 +08:00
Richard Shiue 6bcef33d05 chore: trim markdown text (#7242)
* chore: trim markdown text

* chore: trim before saving
2025-01-20 11:47:03 +08:00
Lucas aa8c9bad9f fix: remove app from homepage url (#7233)
* fix: remove app from namespace url

* fix: banner issue on mobile

* Revert "fix: remove app from namespace url"

This reverts commit 4bd5f3f590.

* fix: remove app from namespace url
2025-01-17 20:55:22 +08:00
Richard Shiue 1723886f3a feat: save messages as a new page (#7224) 2025-01-17 20:38:55 +08:00
FakhriAzzouz 63c7f7b6fe chore: update ar-SA translations (#7216)
* 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-01-17 10:08:09 +08:00
Lucas f35dfaf525 fix: improve the document diff function to prevent partial ordering issues (#7217)
* fix: improve the document diff function to prevent partial ordering issues

* fix: improve the document diff function to prevent partial ordering issues

* fix: nested block padding issues

* fix: improve the document diff function to prevent partial ordering issues

* chore: update editor version

* test: add no diff test and update text diff test

* test: delete and insert text diff with different id

* test: insert single text / delete single text tests

* test: multiple delete and update diff

* test: multiple insert and update diff

* chore: revert cargo changes

* chore: remove unused code

* chore: optimize the code logic
2025-01-16 21:01:23 +08:00
Richard Shiue eead2d20f5 fix: pass alwaysDistributeColumnWidths to simple table block (#7228) 2025-01-16 16:42:24 +08:00
Richard Shiue f73c2540c6 chore: reorganize code (#7206) 2025-01-15 19:57:47 +08:00
793 changed files with 37372 additions and 12918 deletions

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.3"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+11 -11
View File
@@ -25,7 +25,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.2"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
@@ -40,7 +40,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -74,7 +74,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ windows-latest ]
os: [windows-latest]
include:
- os: windows-latest
flutter_profile: development-windows-x86
@@ -101,7 +101,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ macos-latest ]
os: [macos-latest]
include:
- os: macos-latest
flutter_profile: development-mac-x86_64
@@ -123,12 +123,12 @@ jobs:
flutter_profile: ${{ matrix.flutter_profile }}
unit_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -217,11 +217,11 @@ jobs:
shell: bash
cloud_integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -340,13 +340,13 @@ jobs:
shell: bash
integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
os: [ubuntu-latest]
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
+1 -1
View File
@@ -18,7 +18,7 @@ on:
- "!frontend/appflowy_web_app/**"
env:
FLUTTER_VERSION: "3.22.3"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
concurrency:
+11 -11
View File
@@ -6,7 +6,7 @@ on:
- "*"
env:
FLUTTER_VERSION: "3.22.0"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
jobs:
@@ -232,10 +232,10 @@ jobs:
matrix:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -336,12 +336,12 @@ jobs:
matrix:
job:
- {
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-20.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-20.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
+1 -1
View File
@@ -10,7 +10,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.0"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
jobs:
+61 -1
View File
@@ -1,6 +1,66 @@
# 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
- Support locking page
- Support uploading svg file as icon
- Support the slash, at, and plus menus on mobile
### Bug Fixes
- Gallery not rendering in row page
- Save image should not copy the image (mobile)
- Support exporting more content to markdown
## Version 0.8.2 - 23/01/2025
### New Features
- Customized database view icons
- Support for uploading images as custom icons
- Enabled selecting multiple AI messages to save into a document
- Added the ability to scale the app's display size on mobile
- Support for pasting image links without file extensions
### Bug Fixes
- Fixed an issue where pasting tables from other apps wasn't working
- Fixed homepage URL issues in Settings
- Fixed an issue where the 'Cancel' button was not visible on the Shortcuts page
## Version 0.8.1 - 14/01/2025
### New Features:
### New Features
- AI Chat Layout Options: Customize how AI responses appear with new layouts—List, Table, Image with Text, and Media Only
- DALL-E Integration: Generate stunning AI images from text prompts, now available in AI Chat
- Improved Desktop Search: Find what you need faster using keywords or by asking questions in natural language
+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
@@ -4,7 +4,7 @@ workflows:
instance_type: mac_mini_m2
max_build_duration: 30
environment:
flutter: 3.22.3
flutter: 3.27.4
xcode: latest
cocoapods: default
+123 -123
View File
@@ -1,125 +1,125 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
+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.1"
APPFLOWY_VERSION = "0.8.8"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -43,6 +43,8 @@
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
<meta-data android:name="flutterEmbedding" android:value="2" />
<meta-data android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM9.25 3.75C9.25 4.44036 8.69036 5 8 5C7.30964 5 6.75 4.44036 6.75 3.75C6.75 3.05964 7.30964 2.5 8 2.5C8.69036 2.5 9.25 3.05964 9.25 3.75ZM12 8H9.41901L11.2047 13H9.081L8 9.97321L6.91901 13H4.79528L6.581 8H4V6H12V8Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 617 B

@@ -0,0 +1,12 @@
output: dist/
releases:
- name: dev
jobs:
- name: release-dev-linux-deb
package:
platform: linux
target: deb
- name: release-dev-linux-rpm
package:
platform: linux
target: rpm
+36
View File
@@ -0,0 +1,36 @@
-----BEGIN PUBLIC KEY-----
MIIGQzCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0YruaT
rrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7tJ8mG
4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy7xyw
+sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL7iTV
KiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqHOpf5
b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdpqm4Z
QRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFyJiJW
YWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ5EhG
G4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAhLswu
6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPhAsVA
6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxCxMTp
q1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIAPxbd
0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+Vuix/
4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/8Wrb
K13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqTQJg7
hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19tKcO
s6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQdbsCz
Axp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzfJ4v4
uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6TjcfV
Wthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NClWgZn
ixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3wm7NB
+fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcTilaN
C9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkDggIGAAKCAgEAt1DHYZoeXY0r
vYXmxdNO6zfnbz1GGZHXpakzm9h4BrxPDP5J8DQ9ZeVVKg5+cU9AyMO3cZHp7wkx
k6IB+ZDUpqO1D3lWriRl2fI8cS4edI0fzpnW1nyhhFD4MbKmP+v27aH+DhZ4Up3y
GMmJTLmKiYx1EgZp7Sx77PBYDVMsKKd3h9+Hjp2YtUTfD2lleAmC+wcQGZiNtGw/
eKpsmUVnWrepOdntWTtCQi1OvfcHaF2QmgktCq+68hbDNYWaXmzVIiQqrdv/zzOG
hCFIrRGWemrxL0iFG4Pzc4UfOINsISQLcUxRuF6pQWPxF8O/mWKfzAeqWxmIujUM
EoSEuI3yQ8VjlYpW/8FSK7UhgnHBHOpCJWPWs/vQXAnaUR2PYyzuIzhVEhFs8YA8
iBIKnixIC2hu0YbEk3TBr/TRcbd7mDw9Mq7NT88xzdU13+Wh+4zhdX3rtBHYzBtI
7GaONGUNyY4h0duoyLpH6dxevaeKN6/bEdzYESjoE58QA88CpnAZGhJVphAba4cb
w6GTDhK3RlPWh6hRqJwLDILGtnJS3UKeBDRmKMqNuqmHqPjyAAvt9JBO8lzjoLgf
1cDsXHNWBVwA2jsX2CukNJPlY1Fa3MWhdaUXmy6QGMSisr1sptvBt1Phry8T2u+P
Y29SB4jvwqls268rP0cWqy4WXwlVwuc=
-----END PUBLIC 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(
@@ -1,5 +1,6 @@
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'database/database_test_runner.dart' as database_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
@@ -28,4 +29,7 @@ Future<void> main() async {
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
// database
database_test_runner.main();
}
@@ -31,12 +31,6 @@ void main() {
await tester.enterUserName('local_user');
// Scroll to sign-in
await tester.scrollUntilVisible(
find.byType(AccountSignInOutButton),
100,
scrollable: find.findSettingsScrollable(),
);
await tester.tapButton(find.byType(AccountSignInOutButton));
// sign up with Google
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../../shared/constants.dart';
import '../../../shared/database_test_op.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// copy link to block
group('database image:', () {
testWidgets('insert image', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open the first row detail page and upload an image
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Grid,
pageName: 'database image',
);
await tester.openFirstRowDetailPage();
// insert an image block
{
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
}
// upload an image
{
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
final file = File(imagePath)
..writeAsBytesSync(image.buffer.asUint8List());
mockPickFilePaths(
paths: [imagePath],
);
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
);
await tester.pumpAndSettle();
expect(find.byType(ResizableImage), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
// remove the temp file
file.deleteSync();
}
});
});
}
@@ -0,0 +1,9 @@
import 'package:integration_test/integration_test.dart';
import 'database_image_test.dart' as database_image_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
database_image_test.main();
}
@@ -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
@@ -57,12 +57,6 @@ void main() {
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Scroll to sign-in
await tester.scrollUntilVisible(
find.byType(AccountSignInOutButton),
100,
scrollable: find.findSettingsScrollable(),
);
await tester.tapButton(find.byType(AccountSignInOutButton));
tester.expectToSeeGoogleLoginButton();
@@ -120,6 +120,10 @@ void main() {
widget is PublishedViewItem &&
widget.publishInfoView.view.name == pageName,
);
if (pageItem.evaluate().isEmpty) {
return;
}
expect(pageItem, findsOneWidget);
// comment it out because it's not allowed to update the namespace in free plan
@@ -257,6 +261,10 @@ More actions for published page:
widget is PublishedViewItem &&
widget.publishInfoView.view.name == pageName,
);
if (pageItem.evaluate().isEmpty) {
return;
}
expect(pageItem, findsOneWidget);
final copyLinkItem = find.text(LocaleKeys.shareAction_copyLink.tr());
@@ -0,0 +1,190 @@
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.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/database_layout_ext.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
testWidgets('change icon', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await tester.loadIcon();
const pageName = 'Database';
await tester.createNewPageWithNameUnderParent(
layout: ViewLayoutPB.Grid,
name: pageName,
);
/// create board
final addButton = find.byType(AddDatabaseViewButton);
await tester.tapButton(addButton);
await tester.tapButton(
find.text(
'${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Board.layoutName}',
findRichText: true,
),
);
/// create calendar
await tester.tapButton(addButton);
await tester.tapButton(
find.text(
'${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Calendar.layoutName}',
findRichText: true,
),
);
final databaseTabBarItem = find.byType(DatabaseTabBarItem);
expect(databaseTabBarItem, findsNWidgets(3));
final gridItem = databaseTabBarItem.first,
boardItem = databaseTabBarItem.at(1),
calendarItem = databaseTabBarItem.last;
/// change the icon of grid
/// the first tapping is to select specific item
/// the second tapping is to show the menu
await tester.tapButton(gridItem);
await tester.tapButton(gridItem);
/// change icon
await tester
.tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr()));
await tester.tapIcon(iconData, enableColor: false);
final gridIcon = find.descendant(
of: gridItem,
matching: find.byType(RawEmojiIconWidget),
);
final gridIconWidget =
gridIcon.evaluate().first.widget as RawEmojiIconWidget;
final iconsData = IconsData.fromJson(jsonDecode(iconData.emoji));
final gridIconsData =
IconsData.fromJson(jsonDecode(gridIconWidget.emoji.emoji));
expect(gridIconsData.iconName, iconsData.iconName);
/// change the icon of board
await tester.tapButton(boardItem);
await tester.tapButton(boardItem);
await tester
.tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr()));
await tester.tapIcon(iconData, enableColor: false);
final boardIcon = find.descendant(
of: boardItem,
matching: find.byType(RawEmojiIconWidget),
);
final boardIconWidget =
boardIcon.evaluate().first.widget as RawEmojiIconWidget;
final boardIconsData =
IconsData.fromJson(jsonDecode(boardIconWidget.emoji.emoji));
expect(boardIconsData.iconName, iconsData.iconName);
/// change the icon of calendar
await tester.tapButton(calendarItem);
await tester.tapButton(calendarItem);
await tester
.tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr()));
await tester.tapIcon(iconData, enableColor: false);
final calendarIcon = find.descendant(
of: calendarItem,
matching: find.byType(RawEmojiIconWidget),
);
final calendarIconWidget =
calendarIcon.evaluate().first.widget as RawEmojiIconWidget;
final calendarIconsData =
IconsData.fromJson(jsonDecode(calendarIconWidget.emoji.emoji));
expect(calendarIconsData.iconName, iconsData.iconName);
});
testWidgets('change database icon from sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await tester.loadIcon();
final icon = IconsData.fromJson(jsonDecode(iconData.emoji)), emoji = '😄';
const pageName = 'Database';
await tester.createNewPageWithNameUnderParent(
layout: ViewLayoutPB.Grid,
name: pageName,
);
final viewItem = find.descendant(
of: find.byType(SidebarFolder),
matching: find.byWidgetPredicate(
(w) => w is ViewItem && w.view.name == pageName,
),
);
/// change icon to emoji
await tester.tapButton(
find.descendant(
of: viewItem,
matching: find.byType(FlowySvg),
),
);
await tester.tapEmoji(emoji);
final iconWidget = find.descendant(
of: viewItem,
matching: find.byType(RawEmojiIconWidget),
);
expect(
(iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji,
emoji,
);
/// the icon will not be displayed in database item
Finder databaseIcon = find.descendant(
of: find.byType(DatabaseTabBarItem),
matching: find.byType(FlowySvg),
);
expect(
(databaseIcon.evaluate().first.widget as FlowySvg).svg,
FlowySvgs.icon_grid_s,
);
/// change emoji to icon
await tester.tapButton(iconWidget);
await tester.tapIcon(iconData);
expect(
(iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji,
iconData.emoji,
);
databaseIcon = find.descendant(
of: find.byType(DatabaseTabBarItem),
matching: find.byType(RawEmojiIconWidget),
);
final databaseIconWidget =
databaseIcon.evaluate().first.widget as RawEmojiIconWidget;
final databaseIconsData =
IconsData.fromJson(jsonDecode(databaseIconWidget.emoji.emoji));
expect(icon.svgString, databaseIconsData.svgString);
expect(icon.color, isNotEmpty);
expect(icon.color, databaseIconsData.color);
/// the icon in database item should not show the color
expect(databaseIconWidget.enableColor, false);
});
}
@@ -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,
@@ -19,7 +19,7 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('copy and paste in document', () {
group('copy and paste in document:', () {
testWidgets('paste multiple lines at the first line', (tester) async {
// mock the clipboard
const lines = 3;
@@ -172,305 +172,314 @@ void main() {
},
);
});
});
testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test';
testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test';
await tester.pasteContent(
plainText: plainText,
beforeTest: (editorState) async {
final transaction = editorState.transaction;
transaction.insertNodes(
[0],
[
Node(
type: BulletedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "bullet list"},
],
},
),
],
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection(
start: Position(path: [0], offset: 7),
end: Position(path: [0], offset: 11),
);
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toPlainText(), 'bullet test');
expect(node?.type, BulletedListBlockKeys.type);
},
);
});
testWidgets('paste image(png) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('png', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets('paste image(jpeg) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('jpeg', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets('paste image(gif) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.gif');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('gif', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets(
'format the selected text to href when pasting url if available',
(tester) async {
const text = 'appflowy';
const url = 'https://appflowy.io';
await tester.pasteContent(
plainText: url,
plainText: plainText,
beforeTest: (editorState) async {
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
final transaction = editorState.transaction;
transaction.insertNodes(
[0],
[
Node(
type: BulletedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "bullet list"},
],
},
),
],
);
},
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': text,
'attributes': {'href': url},
}
]);
},
);
},
);
// https://github.com/AppFlowy-IO/AppFlowy/issues/3263
testWidgets(
'paste the image from clipboard when html and image are both available',
(tester) async {
const html =
'''<meta charset='utf-8'><img src="https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png" alt="image"/>''';
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(
html: html,
image: ('png', bytes),
(editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
},
);
},
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection(
start: Position(path: [0], offset: 7),
end: Position(path: [0], offset: 11),
);
testWidgets('paste the html content contains section', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">AppFlowy</strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">Hello World</strong></span></section>''';
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
});
});
testWidgets('paste the html from google translation', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">new force</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">Assessment focus: potential motivations, empathy</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;"></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-Line truncated
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 8);
});
});
testWidgets(
'auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
// hover on the link preview block
// click the more button
// and select convert to link
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toPlainText(), 'bullet test');
expect(node?.type, BulletedListBlockKeys.type);
},
);
});
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
expect(textNode.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'ctrl/cmd+z to undo the auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
testWidgets('paste image(png) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('png', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'paste the nodes start with non-delta node',
(tester) async {
await tester.pasteContent((_) {});
const text = 'Hello World';
final editorState = tester.editor.getCurrentEditorState();
final transaction = editorState.transaction;
// [image_block]
// [paragraph_block]
transaction.insertNodes([
0,
], [
customImageNode(url: ''),
paragraphNode(text: text),
]);
await editorState.apply(transaction);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(0);
// select all and copy
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
// put the cursor to the end of the paragraph block
await tester.editor.tapLineOfEditorAt(0);
// paste the content
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
// expect the image and the paragraph block are inserted below the cursor
expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type);
expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type);
},
);
testWidgets('paste the url without protocol', (tester) async {
// paste the image that from local file
const plainText = '1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
testWidgets('paste the image url', (tester) async {
const plainText = 'https://appflowy.io/1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
testWidgets('paste image(jpeg) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('jpeg', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
});
const testMarkdownText = '''
testWidgets('paste image(gif) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.gif');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('gif', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets(
'format the selected text to href when pasting url if available',
(tester) async {
const text = 'appflowy';
const url = 'https://appflowy.io';
await tester.pasteContent(
plainText: url,
beforeTest: (editorState) async {
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
);
},
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': text,
'attributes': {'href': url},
}
]);
},
);
},
);
// https://github.com/AppFlowy-IO/AppFlowy/issues/3263
testWidgets(
'paste the image from clipboard when html and image are both available',
(tester) async {
const html =
'''<meta charset='utf-8'><img src="https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png" alt="image"/>''';
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(
html: html,
image: ('png', bytes),
(editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
},
);
},
);
testWidgets('paste the html content contains section', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">AppFlowy</strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">Hello World</strong></span></section>''';
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
});
});
testWidgets('paste the html from google translation', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">new force</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">Assessment focus: potential motivations, empathy</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;"></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: bordeLine truncated
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 8);
});
});
testWidgets(
'auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
// hover on the link preview block
// click the more button
// and select convert to link
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await tester.pumpAndSettle();
},
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
expect(textNode.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'ctrl/cmd+z to undo the auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'paste the nodes start with non-delta node',
(tester) async {
await tester.pasteContent((_) {});
const text = 'Hello World';
final editorState = tester.editor.getCurrentEditorState();
final transaction = editorState.transaction;
// [image_block]
// [paragraph_block]
transaction.insertNodes([
0,
], [
customImageNode(url: ''),
paragraphNode(text: text),
]);
await editorState.apply(transaction);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(0);
// select all and copy
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
// put the cursor to the end of the paragraph block
await tester.editor.tapLineOfEditorAt(0);
// paste the content
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
// expect the image and the paragraph block are inserted below the cursor
expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type);
expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type);
},
);
testWidgets('paste the url without protocol', (tester) async {
// paste the image that from local file
const plainText = '1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
testWidgets('paste the image url', (tester) async {
const plainText = 'http://example.com/1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
testWidgets('paste image url without extension', (tester) async {
const plainText =
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
await tester.pasteContent(plainText: plainText, (editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
const testMarkdownText = '''
# I'm h1
## I'm h2
### I'm h3
@@ -478,34 +487,35 @@ void main() {
##### I'm h5
###### I'm h6''';
testWidgets('paste markdowns', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
expect(text, 'I\'m h$i');
}
},
);
});
testWidgets('paste markdowns', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
expect(text, 'I\'m h$i');
}
},
);
});
testWidgets('paste markdowns as plain', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
pasteAsPlain: true,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
final expectText = '${'#' * i} I\'m h$i';
expect(text, expectText);
}
},
);
testWidgets('paste markdowns as plain', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
pasteAsPlain: true,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
final expectText = '${'#' * i} I\'m h$i';
expect(text, expectText);
}
},
);
});
});
}
@@ -523,7 +533,7 @@ extension on WidgetTester {
await tapAnonymousSignInButton();
// create a new document
await createNewPageWithNameUnderParent(name: 'Test Document');
await createNewPageWithNameUnderParent();
// tap the editor
await tapButton(find.byType(AppFlowyEditor));
@@ -546,7 +556,7 @@ extension on WidgetTester {
isShiftPressed: pasteAsPlain,
isMetaPressed: Platform.isMacOS,
);
await pumpAndSettle();
await pumpAndSettle(const Duration(milliseconds: 1000));
test(editor.getCurrentEditorState());
}
@@ -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();
@@ -5,6 +5,7 @@ import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -209,4 +210,137 @@ void main() {
);
}
});
testWidgets('Update page custom image icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
final iconData = await tester.prepareImageIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page custom svg icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
final iconData = await tester.prepareSvgIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page custom svg icon in title bar by pasting a link',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
const testIconLink =
'https://beta.appflowy.cloud/api/file_storage/008e6f23-516b-4d8d-b1fe-2b75c51eee26/v1/blob/6bdf8dff%2D0e54%2D4d35%2D9981%2Dcde68bef1141/BGpLnRtb3AGBNgSJsceu70j83zevYKrMLzqsTIJcBeI=.svg';
/// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
/// update its icon
await tester.updatePageIconInTitleBarByPasteALink(
name: value.name,
layout: value,
iconLink: testIconLink,
);
/// check if there is a svg in page
final pageName = tester.findPageName(
value.name,
layout: value,
);
final imageInPage = find.descendant(
of: pageName,
matching: find.byType(SvgPicture),
);
expect(imageInPage, findsOneWidget);
/// check if there is a svg in title
final imageInTitle = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.byWidgetPredicate((w) {
if (w is! SvgPicture) return false;
final loader = w.bytesLoader;
if (loader is! SvgFileLoader) return false;
return loader.file.path.endsWith('.svg');
}),
);
expect(imageInTitle, findsOneWidget);
}
});
}
@@ -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);
});
});
}
@@ -1,12 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
import '../document/document_with_database_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -18,7 +22,7 @@ void main() {
// mock the file picker
final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.md'),
p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
@@ -28,10 +32,14 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
final markdown = file.readAsStringSync();
expect(markdown, expectedMarkdown);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.md')) {
final markdown = utf8.decode(entry.content);
expect(markdown, expectedMarkdown);
}
}
});
testWidgets(
@@ -57,7 +65,7 @@ void main() {
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
'${shareButtonState.view.name}.md',
'${shareButtonState.view.name}.zip',
),
);
@@ -69,10 +77,44 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.md')) {
final markdown = utf8.decode(entry.content);
expect(markdown, expectedMarkdown);
}
}
},
);
testWidgets('share the markdown with database', (tester) async {
final context = await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
// mock the file picker
final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
await tester.tapMarkdownButton();
// expect to see the success dialog
tester.expectToExportSuccess();
final file = File(path);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
bool hasCsvFile = false;
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.csv')) {
hasCsvFile = true;
}
}
expect(hasCsvFile, true);
});
});
}
@@ -13,7 +13,6 @@ void main() {
hotkeys_test.main();
emoji_shortcut_test.main();
hotkeys_test.main();
emoji_shortcut_test.main();
share_markdown_test.main();
import_files_test.main();
zoom_in_out_test.main();
@@ -1,9 +1,10 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
import 'desktop/database/database_icon_test.dart' as database_icon_test;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
as code_language_selector;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
Future<void> main() async {
await runIntegration9OnDesktop();
@@ -15,4 +16,5 @@ Future<void> runIntegration9OnDesktop() async {
first_test.main();
tabs_test.main();
code_language_selector.main();
database_icon_test.main();
}
@@ -0,0 +1,56 @@
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const title = 'Test At Menu';
group('at menu', () {
testWidgets('show at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
final menuWidget = find.byType(MobileInlineActionsMenu);
expect(menuWidget, findsOneWidget);
});
testWidgets('search by at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
expect(actionWidgets, findsNWidgets(2));
});
testWidgets('tap at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
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);
});
});
}
@@ -1,8 +1,11 @@
import 'package:integration_test/integration_test.dart';
import 'at_menu_test.dart' as at_menu;
import 'at_menu_test.dart' as at_menu_test;
import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'simple_table_test.dart' as simple_table_test;
import 'slash_menu_test.dart' as slash_menu;
import 'title_test.dart' as title_test;
import 'toolbar_test.dart' as toolbar_test;
@@ -13,6 +16,9 @@ void main() {
title_test.main();
page_style_test.main();
plus_menu_test.main();
at_menu_test.main();
simple_table_test.main();
toolbar_test.main();
slash_menu.main();
at_menu.main();
}
@@ -0,0 +1,104 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document title:', () {
testWidgets('update page custom image icon in title bar', (tester) async {
await tester.launchInAnonymousMode();
/// prepare local image
final iconData = await tester.prepareImageIcon();
/// create an empty page
await tester
.tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey));
/// show Page style page
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
final pageStyleIcon = find.byType(PageStyleIcon);
final iconInPageStyleIcon = find.descendant(
of: pageStyleIcon,
matching: find.byType(RawEmojiIconWidget),
);
expect(iconInPageStyleIcon, findsNothing);
/// show icon picker
await tester.tapButton(pageStyleIcon);
/// upload custom icon
await tester.pickImage(iconData);
/// check result
final documentPage = find.byType(MobileDocumentScreen);
final rawEmojiIconFinder = find
.descendant(
of: documentPage,
matching: find.byType(RawEmojiIconWidget),
)
.last;
final rawEmojiIconWidget =
rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget;
final iconDataInWidget = rawEmojiIconWidget.emoji;
expect(iconDataInWidget.type, FlowyIconType.custom);
final imageFinder =
find.descendant(of: rawEmojiIconFinder, matching: find.byType(Image));
expect(imageFinder, findsOneWidget);
});
testWidgets('update page custom svg icon in title bar', (tester) async {
await tester.launchInAnonymousMode();
/// prepare local image
final iconData = await tester.prepareSvgIcon();
/// create an empty page
await tester
.tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey));
/// show Page style page
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
final pageStyleIcon = find.byType(PageStyleIcon);
final iconInPageStyleIcon = find.descendant(
of: pageStyleIcon,
matching: find.byType(RawEmojiIconWidget),
);
expect(iconInPageStyleIcon, findsNothing);
/// show icon picker
await tester.tapButton(pageStyleIcon);
/// upload custom icon
await tester.pickImage(iconData);
/// check result
final documentPage = find.byType(MobileDocumentScreen);
final rawEmojiIconFinder = find
.descendant(
of: documentPage,
matching: find.byType(RawEmojiIconWidget),
)
.last;
final rawEmojiIconWidget =
rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget;
final iconDataInWidget = rawEmojiIconWidget.emoji;
expect(iconDataInWidget.type, FlowyIconType.custom);
final svgFinder = find.descendant(
of: rawEmojiIconFinder,
matching: find.byType(SvgPicture),
);
expect(svgFinder, findsOneWidget);
});
});
}
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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';
@@ -85,5 +88,32 @@ void main() {
equals(Selection.collapsed(Position(path: [2]))),
);
});
const title = 'Test Plus Menu';
testWidgets('show plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
final menuWidget = find.byType(MobileInlineActionsMenu);
expect(menuWidget, findsOneWidget);
});
testWidgets('search by plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
expect(actionWidgets, findsNWidgets(2));
});
testWidgets('tap plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tap(actionWidgets.last);
expect(find.byType(MentionPageBlock), findsOneWidget);
});
});
}
@@ -0,0 +1,84 @@
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const title = 'Test Slash Menu';
group('slash menu', () {
testWidgets('show slash menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowSlashMenu(title);
final menuWidget = find.byType(MobileSelectionMenuWidget);
expect(menuWidget, findsOneWidget);
final items =
(menuWidget.evaluate().first.widget as MobileSelectionMenuWidget)
.items;
int i = 0;
for (final item in items) {
final localItem = mobileItems[i];
expect(item.name, localItem.name);
i++;
}
});
testWidgets('search by slash menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowSlashMenu(title);
const searchText = 'Heading';
await tester.ime.insertText(searchText);
final itemWidgets = find.byType(MobileSelectionMenuItemWidget);
int number = 0;
for (final item in mobileItems) {
if (item is MobileSelectionMenuItem) {
for (final childItem in item.children) {
if (childItem.name
.toLowerCase()
.contains(searchText.toLowerCase())) {
number++;
}
}
} else {
if (item.name.toLowerCase().contains(searchText.toLowerCase())) {
number++;
}
}
}
expect(itemWidgets, findsNWidgets(number));
});
testWidgets('tap to show submenu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile(title);
await tester.editor.tapLineOfEditorAt(0);
final listview = find.descendant(
of: find.byType(MobileSelectionMenuWidget),
matching: find.byType(ListView),
);
for (final item in mobileItems) {
if (item is! MobileSelectionMenuItem) continue;
await tester.editor.showSlashMenu();
await tester.scrollUntilVisible(
find.text(item.name),
50,
scrollable: listview,
duration: const Duration(milliseconds: 250),
);
await tester.tap(find.text(item.name));
final childrenLength = ((listview.evaluate().first.widget as ListView)
.childrenDelegate as SliverChildListDelegate)
.children
.length;
expect(childrenLength, item.children.length);
}
});
});
}
@@ -0,0 +1,48 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/setting/settings_popup_menu.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('test for change scale factor', (tester) async {
await tester.launchInAnonymousMode();
/// tap [Setting] button
await tester.tapButton(find.byType(HomePageSettingsPopupMenu));
await tester
.tapButton(find.text(LocaleKeys.settings_popupMenuItem_settings.tr()));
/// tap [Font Scale Factor]
await tester.tapButton(
find.text(LocaleKeys.settings_appearance_fontScaleFactor.tr()),
);
/// drag slider
final slider = find.descendant(
of: find.byType(FontSizeStepper),
matching: find.byType(Slider),
);
await tester.slideToValue(slider, 0.8);
expect(appflowyScaleFactor, 0.8);
await tester.slideToValue(slider, 0.9);
expect(appflowyScaleFactor, 0.9);
await tester.slideToValue(slider, 1.0);
expect(appflowyScaleFactor, 1.0);
await tester.slideToValue(slider, 1.1);
expect(appflowyScaleFactor, 1.1);
await tester.slideToValue(slider, 1.2);
expect(appflowyScaleFactor, 1.2);
});
}
@@ -13,6 +13,7 @@ import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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;
@@ -235,6 +236,25 @@ extension AppFlowyTestBase on WidgetTester {
Future<void> wait(int milliseconds) async {
await pumpAndSettle(Duration(milliseconds: milliseconds));
}
Future<void> slideToValue(
Finder slider,
double value, {
double paddingOffset = 24.0,
}) async {
final sliderWidget = slider.evaluate().first.widget as Slider;
final range = sliderWidget.max - sliderWidget.min;
final initialRate = (value - sliderWidget.min) / range;
final totalWidth = getSize(slider).width - (2 * paddingOffset);
final zeroPoint = getTopLeft(slider) +
Offset(
paddingOffset + initialRate * totalWidth,
getSize(slider).height / 2,
);
final calculatedOffset = value * (totalWidth / 100);
await dragFrom(zeroPoint, Offset(calculatedOffset, 0));
await pumpAndSettle();
}
}
extension AppFlowyFinderTestBase on CommonFinders {
@@ -50,6 +50,8 @@ 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;
import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'emoji.dart';
@@ -615,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]
@@ -671,10 +673,31 @@ extension CommonOperations on WidgetTester {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
} else if (icon.type == FlowyIconType.custom) {
await pickImage(icon);
}
await pumpAndSettle();
}
Future<void> updatePageIconInTitleBarByPasteALink({
required String name,
required ViewLayoutPB layout,
required String iconLink,
}) async {
await openPage(
name,
layout: layout,
);
final title = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(name),
);
await tapButton(title);
await tapButton(find.byType(EmojiPickerButton));
await pasteImageLinkAsIcon(iconLink);
await pumpAndSettle();
}
Future<void> openNotificationHub({int tabIndex = 0}) async {
final finder = find.descendant(
of: find.byType(NotificationButton),
@@ -933,6 +956,45 @@ extension CommonOperations on WidgetTester {
),
);
}
Future<EmojiIconData> prepareImageIcon() async {
final imagePath = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final localImagePath = p.join(tempDirectory.path, 'sample.jpeg');
final imageFile = File(localImagePath)
..writeAsBytesSync(imagePath.buffer.asUint8List());
return EmojiIconData.custom(imageFile.path);
}
Future<EmojiIconData> prepareSvgIcon() async {
final imagePath = await rootBundle.load('assets/test/images/sample.svg');
final tempDirectory = await getTemporaryDirectory();
final localImagePath = p.join(tempDirectory.path, 'sample.svg');
final imageFile = File(localImagePath)
..writeAsBytesSync(imagePath.buffer.asUint8List());
return EmojiIconData.custom(imageFile.path);
}
/// create new page and show slash menu
Future<void> createPageAndShowSlashMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showSlashMenu();
}
/// create new page and show at menu
Future<void> createPageAndShowAtMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showAtMenu();
}
/// create new page and show plus menu
Future<void> createPageAndShowPlusMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showPlusMenu();
}
}
extension SettingsFinder on CommonFinders {
@@ -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
@@ -1464,6 +1463,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
);
await tapButton(button);
await tapButtonWithName(LocaleKeys.button_delete.tr());
}
Future<void> dragDropRescheduleCalendarEvent() async {
@@ -1571,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,16 +1,24 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_uploader.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
import 'common_operations.dart';
extension EmojiTestExtension on WidgetTester {
Future<void> tapEmoji(String emoji) async {
@@ -21,7 +29,7 @@ extension EmojiTestExtension on WidgetTester {
await tapButton(emojiWidget);
}
Future<void> tapIcon(EmojiIconData icon) async {
Future<void> tapIcon(EmojiIconData icon, {bool enableColor = true}) async {
final iconsData = IconsData.fromJson(jsonDecode(icon.emoji));
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
@@ -40,23 +48,97 @@ extension EmojiTestExtension on WidgetTester {
);
await tapButton(selectedSvg.first);
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) {
return true;
if (enableColor) {
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(
int.parse(iconsData.color ?? builtInSpaceColors.first),
)) {
return true;
}
}
}
}
return false;
}),
return false;
}),
);
await tapButton(selectedColor);
}
}
Future<void> pickImage(EmojiIconData icon) async {
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
/// switch to custom tab
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.custom.tr),
);
await tapButton(selectedColor);
expect(iconTab, findsOneWidget);
await tapButton(iconTab);
/// mock for dragging image
final dropTarget = find.descendant(
of: find.byType(IconUploader),
matching: find.byType(DropTarget),
);
expect(dropTarget, findsOneWidget);
final dropTargetWidget = dropTarget.evaluate().first.widget as DropTarget;
dropTargetWidget.onDragDone?.call(
DropDoneDetails(
files: [DropItemFile(icon.emoji)],
localPosition: Offset.zero,
globalPosition: Offset.zero,
),
);
await pumpAndSettle(const Duration(seconds: 3));
/// confirm to upload
final confirmButton = find.descendant(
of: find.byType(IconUploader),
matching: find.byType(PrimaryRoundedButton),
);
await tapButton(confirmButton);
}
Future<void> pasteImageLinkAsIcon(String link) async {
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
/// switch to custom tab
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.custom.tr),
);
expect(iconTab, findsOneWidget);
await tapButton(iconTab);
// mock the clipboard
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
// paste the link
await simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await pumpAndSettle(const Duration(seconds: 5));
/// confirm to upload
final confirmButton = find.descendant(
of: find.byType(IconUploader),
matching: find.byType(PrimaryRoundedButton),
);
await tapButton(confirmButton);
}
}
@@ -7,6 +7,8 @@ import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/appflowy_network_svg.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
@@ -21,6 +23,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'util.dart';
@@ -249,6 +252,23 @@ extension Expectation on WidgetTester {
),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.custom) {
final isSvg = data.emoji.endsWith('.svg');
if (isURL(data.emoji)) {
final image = find.descendant(
of: pageName,
matching: isSvg
? find.byType(FlowyNetworkSvg)
: find.byType(FlowyNetworkImage),
);
expect(image, findsOneWidget);
} else {
final image = find.descendant(
of: pageName,
matching: isSvg ? find.byType(SvgPicture) : find.byType(Image),
);
expect(image, findsOneWidget);
}
}
}
@@ -273,6 +293,30 @@ extension Expectation on WidgetTester {
),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.custom) {
final isSvg = data.emoji.endsWith('.svg');
if (isURL(data.emoji)) {
final image = find.descendant(
of: find.byType(ViewTitleBar),
matching: isSvg
? find.byType(FlowyNetworkSvg)
: find.byType(FlowyNetworkImage),
);
expect(image, findsOneWidget);
} else {
final image = find.descendant(
of: find.byType(ViewTitleBar),
matching: isSvg
? find.byWidgetPredicate((w) {
if (w is! SvgPicture) return false;
final loader = w.bytesLoader;
if (loader is! SvgFileLoader) return false;
return loader.file.path.endsWith('.svg');
})
: find.byType(Image),
);
expect(image, findsOneWidget);
}
}
}
@@ -1,81 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/ai/error.dart';
import 'package:appflowy/ai/openai_client.dart';
import 'package:appflowy/ai/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();
}
}
}
}
}
+18 -11
View File
@@ -1,5 +1,5 @@
PODS:
- app_links (0.0.1):
- app_links (0.0.2):
- Flutter
- appflowy_backend (0.0.1):
- Flutter
@@ -66,6 +66,8 @@ PODS:
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.0.0)
- saver_gallery (0.0.1):
- Flutter
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
@@ -79,7 +81,7 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.3):
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- super_native_extensions (0.0.1):
@@ -90,6 +92,7 @@ PODS:
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -108,13 +111,14 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
@@ -159,23 +163,25 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
saver_gallery:
:path: ".symlinks/plugins/saver_gallery/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
super_native_extensions:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
@@ -186,7 +192,7 @@ SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
@@ -194,17 +200,18 @@ SPEC CHECKSUMS:
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -1,7 +1,7 @@
import UIKit
import Flutter
@UIApplicationMain
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
+73 -70
View File
@@ -1,75 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>AppFlowy</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
<key>CFBundleURLSchemes</key>
<array>
<string>appflowy-flutter</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FLTEnableImpeller</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>AppFlowy</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
<key>CFBundleURLSchemes</key>
<array>
<string>appflowy-flutter</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FLTEnableImpeller</key>
<false />
<key>LSRequiresIPhoneOS</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>AppFlowy needs access to your photos to let you add images to your photo library</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>NSCameraUsageDescription</key>
<string>AppFlowy needs access to your camera to let you add images to your documents from
camera</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true />
<key>UIViewControllerBasedStatusBarAppearance</key>
<false />
</dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>AppFlowy needs access to your camera to let you add images to your documents from camera</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
</plist>
+19
View File
@@ -0,0 +1,19 @@
export 'service/ai_entities.dart';
export 'service/ai_prompt_input_bloc.dart';
export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'service/ai_model_state_notifier.dart';
export 'service/select_model_bloc.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_prompt_text_field.dart';
export 'widgets/prompt_input/file_attachment_list.dart';
export 'widgets/prompt_input/layout_define.dart';
export 'widgets/prompt_input/mention_page_bottom_sheet.dart';
export 'widgets/prompt_input/mention_page_menu.dart';
export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/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,
});
}
@@ -1,143 +0,0 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
import 'package:appflowy_backend/dispatch/dispatch.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 'error.dart';
import 'text_completion.dart';
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({
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,
}) async {
final stream = CompletionStream(
onStart,
onProcess,
onEnd,
onError,
);
final List<String> ragIds = [];
if (objectId != null) {
ragIds.add(objectId);
}
final payload = CompleteTextPB(
text: text,
completionType: completionType,
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? "",
ragIds: ragIds,
);
// 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.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)));
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
int get nativePort => _port.sendPort.nativePort;
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);
}
}
@@ -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,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;
}
@@ -0,0 +1,204 @@
import 'dart:async';
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_entities.dart';
import 'error.dart';
enum LocalAIStreamingState {
notReady,
disabled,
}
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<(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) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
}) async {
final stream = AppFlowyCompletionStream(
onStart: onStart,
processMessage: processMessage,
processAssistMessage: processAssistMessage,
processError: onError,
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
onEnd: onEnd,
);
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: [
if (objectId != null) objectId,
...sourceIds,
].unique(),
history: records,
);
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();
}
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) =>
@@ -17,6 +17,8 @@ class AIError with _$AIError {
enum AIErrorCode {
@JsonValue('AIResponseLimitExceeded')
aiResponseLimitExceeded,
@JsonValue('AIImageResponseLimitExceeded')
aiImageResponseLimitExceeded,
@JsonValue('Other')
other,
}
@@ -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);
}
@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// An animated generating indicator for an AI response
class ChatAILoading extends StatelessWidget {
const ChatAILoading({
class AILoadingIndicator extends StatelessWidget {
const AILoadingIndicator({
super.key,
this.text = "",
this.duration = const Duration(seconds: 1),
@@ -16,8 +16,7 @@ class ChatAILoading 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(
@@ -6,9 +6,7 @@ import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import '../layout_define.dart';
enum SendButtonState { enabled, streaming, disabled }
import 'layout_define.dart';
class PromptInputAttachmentButton extends StatelessWidget {
const PromptInputAttachmentButton({required this.onTap, super.key});
@@ -67,57 +65,3 @@ class PromptInputMentionButton extends StatelessWidget {
);
}
}
class PromptInputSendButton extends StatelessWidget {
const PromptInputSendButton({
super.key,
required this.buttonSize,
required this.iconSize,
required this.state,
required this.onSendPressed,
required this.onStopStreaming,
});
final double buttonSize;
final double iconSize;
final SendButtonState state;
final VoidCallback onSendPressed;
final VoidCallback onStopStreaming;
@override
Widget build(BuildContext context) {
return FlowyIconButton(
width: buttonSize,
icon: switch (state) {
SendButtonState.enabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(iconSize),
color: Theme.of(context).colorScheme.primary,
),
SendButtonState.disabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(iconSize),
color: Theme.of(context).disabledColor,
),
SendButtonState.streaming => FlowySvg(
FlowySvgs.ai_stop_filled_s,
size: Size.square(iconSize),
color: Theme.of(context).colorScheme.primary,
),
},
onPressed: () {
switch (state) {
case SendButtonState.enabled:
onSendPressed();
break;
case SendButtonState.streaming:
onStopStreaming();
break;
case SendButtonState.disabled:
break;
}
},
hoverColor: Colors.transparent,
);
}
}
@@ -1,7 +1,9 @@
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_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';
@@ -11,65 +13,60 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/chat_entity.dart';
import '../layout_define.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_file.dart';
import 'chat_input_span.dart';
import 'chat_mention_page_menu.dart';
import 'predefined_format_buttons.dart';
import 'select_sources_menu.dart';
class DesktopAIPromptInput extends StatefulWidget {
const DesktopAIPromptInput({
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<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
}
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
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;
@override
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();
@@ -87,7 +84,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
@override
void dispose() {
focusNode.dispose();
textController.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
super.dispose();
}
@@ -110,23 +107,14 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
child: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return ChatMentionPageMenu(
anchor: ChatInputAnchor(textFieldKey, layerLink),
textController: textController,
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
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,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
decoration: decoration(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -137,8 +125,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: TextFieldTapRegion(
child: ChatInputFile(
chatId: widget.chatId,
child: PromptInputFile(
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
@@ -146,59 +133,64 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
),
),
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: DesktopAIPromptSizes
.predefinedFormatBarButtonSpacing,
iconSize: DesktopAIPromptSizes
.predefinedFormatIconHeight,
buttonSize: DesktopAIPromptSizes
.predefinedFormatButtonHeight,
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.imageFormat,
predefinedTextFormat: predefinedFormat.textFormat,
onTogglePredefinedFormatSection: () {
setState(() {
showPredefinedFormatSection =
!showPredefinedFormatSection;
});
},
sendButtonState: sendButtonState,
onSendPressed: handleSendPressed,
onStopStreaming: widget.onStopStreaming,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
),
),
),
],
],
);
},
),
],
),
@@ -208,16 +200,38 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
);
}
BoxConstraints getTextFieldConstraints() {
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight +
DesktopAIPromptSizes.actionBarPadding.vertical;
double maxHeight = 300;
if (showPredefinedFormatSection) {
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
BoxDecoration decoration(BuildContext context) {
if (widget.hideDecoration) {
return BoxDecoration();
}
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
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() {
@@ -230,21 +244,21 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
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;
}
}
void handleSendPressed() {
void handleSend() {
if (widget.isStreaming) {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
widget.textController.text.trim(),
);
textController.clear();
widget.textController.clear();
if (trimmedText.isEmpty) {
return;
}
@@ -252,25 +266,32 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
// 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 handleTextControllerChanged() {
if (!textController.value.composing.isCollapsed) {
setState(() {
// update whether send button is clickable
updateSendButtonState();
isComposing = !widget.textController.value.composing.isCollapsed;
});
if (isComposing) {
return;
}
// update whether send button is clickable
setState(() => updateSendButtonState());
// 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) {
@@ -278,6 +299,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
// handle cases where mention a page is cancelled
final textController = widget.textController;
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
@@ -323,13 +345,28 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// 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,
@@ -342,35 +379,78 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
Widget inputTextField() {
return Actions(
actions: buildActions(),
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return _PromptTextField(
key: textFieldKey,
cubit: inputControlCubit,
textController: textController,
textFieldFocusNode: focusNode,
showPredefinedFormatSection: showPredefinedFormatSection,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
// onStartMentioningPage: () {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// inputControlCubit.startSearching(textController.value);
// overlayController.show();
// });
// },
);
},
return Shortcuts(
shortcuts: buildShortcuts(),
child: Actions(
actions: buildActions(),
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
Widget textField = PromptInputTextField(
key: textFieldKey,
editable: state.editable,
cubit: inputControlCubit,
textController: widget.textController,
textFieldFocusNode: focusNode,
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
hintText: state.hintText,
);
if (!state.editable) {
textField = FlowyTooltip(
message: LocaleKeys
.settings_aiPage_keys_localAINotReadyTextFieldPrompt
.tr(),
child: textField,
);
}
return textField;
},
),
),
),
);
}
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 +
DesktopAIChatSizes.inputActionBarMargin.vertical;
return DesktopAIPromptSizes.textFieldContentPadding
.add(EdgeInsets.only(top: top, bottom: bottom));
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
}
return const {
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(),
SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(),
SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(),
};
}
Map<Type, Action<Intent>> buildActions() {
return {
_FocusPreviousItemIntent: CallbackAction<_FocusPreviousItemIntent>(
@@ -404,7 +484,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
orElse: () {},
);
} else {
handleSendPressed();
handleSend();
}
return;
},
@@ -413,115 +493,6 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
}
class _PromptTextField extends StatefulWidget {
const _PromptTextField({
super.key,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
this.showPredefinedFormatSection = false,
this.hintText = "",
// this.onStartMentioningPage,
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
final bool showPredefinedFormatSection;
final String hintText;
// final void Function()? onStartMentioningPage;
@override
State<_PromptTextField> createState() => _PromptTextFieldState();
}
class _PromptTextFieldState extends State<_PromptTextField> {
bool isComposing = false;
@override
void initState() {
super.initState();
// widget.textFieldFocusNode.onKeyEvent = handleKeyEvent;
widget.textController.addListener(onTextChanged);
}
@override
void dispose() {
// widget.textFieldFocusNode.onKeyEvent = null;
widget.textController.removeListener(onTextChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: buildShortcuts(),
child: ExtendedTextField(
controller: widget.textController,
focusNode: widget.textFieldFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: calculateContentPadding(),
hintText: widget.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: ChatInputTextSpanBuilder(
inputControlCubit: widget.cubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
);
}
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// if (event.character == '@') {
// widget.onStartMentioningPage();
// }
// return KeyEventResult.ignored;
// }
void onTextChanged() {
setState(
() => isComposing = !widget.textController.value.composing.isCollapsed,
);
}
EdgeInsetsGeometry calculateContentPadding() {
final top = widget.showPredefinedFormatSection
? DesktopAIPromptSizes.predefinedFormatButtonHeight
: 0.0;
const bottom = DesktopAIPromptSizes.actionBarHeight;
return DesktopAIPromptSizes.textFieldContentPadding
.add(EdgeInsets.only(top: top, bottom: bottom));
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
}
return const {
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(),
SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(),
SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(),
};
}
}
class _SubmitOrMentionPageIntent extends Intent {
const _SubmitOrMentionPageIntent();
}
@@ -538,54 +509,116 @@ 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.predefinedTextFormat,
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 ImageFormat predefinedFormat;
final TextFormat? predefinedTextFormat;
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) {
return Container(
height: DesktopAIPromptSizes.actionBarHeight,
margin: DesktopAIPromptSizes.actionBarPadding,
height: DesktopAIPromptSizes.actionBarSendButtonSize,
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(
DesktopAIPromptSizes.actionBarButtonSpacing,
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (extraBottomActionButton != null) ...[
extraBottomActionButton!,
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
// _mentionButton(context),
@@ -595,7 +628,7 @@ class _PromptBottomActions extends StatelessWidget {
if (state.supportChatWithFile) ...[
_attachmentButton(context),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
_sendButton(),
@@ -609,15 +642,14 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
predefinedFormat: predefinedFormat,
predefinedTextFormat: predefinedTextFormat,
onTap: onTogglePredefinedFormatSection,
);
}
Widget _selectSourcesButton(BuildContext context) {
Widget _selectSourcesButton() {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
selectedSourcesNotifier: selectedSourcesNotifier,
);
}
@@ -625,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,
// );
// }
@@ -669,8 +687,6 @@ class _PromptBottomActions extends StatelessWidget {
Widget _sendButton() {
return PromptInputSendButton(
buttonSize: DesktopAIPromptSizes.actionBarHeight,
iconSize: DesktopAIPromptSizes.sendButtonSize,
state: sendButtonState,
onSendPressed: onSendPressed,
onStopStreaming: onStopStreaming,
@@ -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';
@@ -8,16 +8,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:styled_widget/styled_widget.dart';
import '../layout_define.dart';
import 'layout_define.dart';
class ChatInputFile extends StatelessWidget {
const ChatInputFile({
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 ChatInputFile extends StatelessWidget {
),
itemCount: files.length,
itemBuilder: (context, index) => ChatFilePreview(
chatId: chatId,
file: files[index],
onDeleted: () => onDeleted(files[index]),
),
@@ -49,13 +46,11 @@ class ChatInputFile 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;
@@ -0,0 +1,42 @@
import 'package:flutter/widgets.dart';
class DesktopAIPromptSizes {
const DesktopAIPromptSizes._();
static const attachedFilesBarPadding =
EdgeInsets.only(left: 8.0, top: 8.0, right: 8.0);
static const attachedFilesPreviewHeight = 48.0;
static const attachedFilesPreviewSpacing = 12.0;
static const predefinedFormatButtonHeight = 28.0;
static const predefinedFormatIconHeight = 16.0;
static const textFieldMinHeight = 36.0;
static const textFieldContentPadding =
EdgeInsetsDirectional.fromSTEB(14.0, 8.0, 14.0, 8.0);
static const actionBarButtonSize = 28.0;
static const actionBarIconSize = 16.0;
static const actionBarSendButtonSize = 32.0;
static const actionBarSendButtonIconSize = 24.0;
}
class MobileAIPromptSizes {
const MobileAIPromptSizes._();
static const attachedFilesBarHeight = 68.0;
static const attachedFilesBarPadding =
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0, bottom: 4.0);
static const attachedFilesPreviewHeight = 56.0;
static const attachedFilesPreviewSpacing = 8.0;
static const predefinedFormatButtonHeight = 32.0;
static const predefinedFormatIconHeight = 20.0;
static const textFieldMinHeight = 32.0;
static const textFieldContentPadding =
EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0);
static const mentionIconSize = 20.0;
static const sendButtonSize = 32.0;
}
@@ -8,7 +8,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'chat_mention_page_menu.dart';
import 'mention_page_menu.dart';
Future<ViewPB?> showPageSelectorSheet(
BuildContext context, {
@@ -19,35 +19,39 @@ const double _noPageHeight = 20.0;
const double _fixedWidth = 360.0;
const double _maxHeight = 328.0;
class ChatInputAnchor {
ChatInputAnchor(this.anchorKey, this.layerLink);
class PromptInputAnchor {
PromptInputAnchor(this.anchorKey, this.layerLink);
final GlobalKey<State<StatefulWidget>> anchorKey;
final LayerLink layerLink;
}
class ChatMentionPageMenu extends StatefulWidget {
const ChatMentionPageMenu({
class PromptInputMentionPageMenu extends StatefulWidget {
const PromptInputMentionPageMenu({
super.key,
required this.anchor,
required this.textController,
required this.onPageSelected,
});
final ChatInputAnchor anchor;
final PromptInputAnchor anchor;
final TextEditingController textController;
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageMenu> createState() => _ChatMentionPageMenuState();
State<PromptInputMentionPageMenu> createState() =>
_PromptInputMentionPageMenuState();
}
class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
class _PromptInputMentionPageMenuState
extends State<PromptInputMentionPageMenu> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
context.read<ChatInputControlCubit>().refreshViews();
if (mounted) {
context.read<ChatInputControlCubit>().refreshViews();
}
});
}
@@ -92,7 +96,7 @@ class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
],
),
child: TextFieldTapRegion(
child: ChatMentionPageList(
child: PromptInputMentionPageList(
onPageSelected: widget.onPageSelected,
),
),
@@ -143,8 +147,8 @@ class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
}
}
class ChatMentionPageList extends StatefulWidget {
const ChatMentionPageList({
class PromptInputMentionPageList extends StatefulWidget {
const PromptInputMentionPageList({
super.key,
required this.onPageSelected,
});
@@ -152,10 +156,12 @@ class ChatMentionPageList extends StatefulWidget {
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageList> createState() => _ChatMentionPageListState();
State<PromptInputMentionPageList> createState() =>
_PromptInputMentionPageListState();
}
class _ChatMentionPageListState extends State<ChatMentionPageList> {
class _PromptInputMentionPageListState
extends State<PromptInputMentionPageList> {
final autoScrollController = SimpleAutoScrollController(
suggestedRowHeight: _itemHeight,
beginGetter: (rect) => rect.top + 8.0,
@@ -5,8 +5,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_library/extended_text_library.dart';
import 'package:flutter/material.dart';
class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
ChatInputTextSpanBuilder({
class PromptInputTextSpanBuilder extends SpecialTextSpanBuilder {
PromptInputTextSpanBuilder({
required this.inputControlCubit,
this.specialTextStyle,
});
@@ -1,25 +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 '../layout_define.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.predefinedTextFormat,
required this.onTap,
});
final bool showFormatBar;
final ImageFormat predefinedFormat;
final TextFormat? predefinedTextFormat;
final VoidCallback onTap;
@override
@@ -49,32 +46,33 @@ class ChangeFormatBar extends StatelessWidget {
const ChangeFormatBar({
super.key,
required this.predefinedFormat,
required this.buttonSize,
required this.iconSize,
required this.spacing,
required this.onSelectPredefinedFormat,
this.showImageFormats = true,
});
final PredefinedFormat? predefinedFormat;
final double buttonSize;
final double iconSize;
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(),
_buildTextFormatButton(context, TextFormat.auto),
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),
_buildTextFormatButton(context, TextFormat.table),
@@ -93,7 +91,8 @@ class ChangeFormatBar extends StatelessWidget {
return;
}
if (format.hasText) {
final textFormat = predefinedFormat?.textFormat ?? TextFormat.auto;
final textFormat =
predefinedFormat?.textFormat ?? TextFormat.paragraph;
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: textFormat),
);
@@ -105,16 +104,17 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: buttonSize,
dimension: _buttonSize,
child: FlowyHover(
isSelected: () => format == predefinedFormat?.imageFormat,
child: Center(
child: FlowySvg(
format.icon,
size: format == ImageFormat.textAndImage
? Size(21.0 / 16.0 * iconSize, iconSize)
: Size.square(iconSize),
? Size(21.0 / 16.0 * _iconSize, _iconSize)
: Size.square(_iconSize),
),
),
),
@@ -151,14 +151,15 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: buttonSize,
dimension: _buttonSize,
child: FlowyHover(
isSelected: () => format == predefinedFormat?.textFormat,
child: Center(
child: FlowySvg(
format.icon,
size: Size.square(iconSize),
size: Size.square(_iconSize),
),
),
),
@@ -166,6 +167,18 @@ class ChangeFormatBar extends StatelessWidget {
),
);
}
double get _buttonSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.predefinedFormatButtonHeight
: DesktopAIPromptSizes.predefinedFormatButtonHeight;
}
double get _iconSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.predefinedFormatIconHeight
: DesktopAIPromptSizes.predefinedFormatIconHeight;
}
}
class PromptInputMobileToggleFormatButton extends StatelessWidget {
@@ -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';
@@ -18,15 +18,17 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
import 'chat_mention_page_menu.dart';
import 'layout_define.dart';
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),
),
],
),
@@ -0,0 +1,89 @@
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';
import 'layout_define.dart';
enum SendButtonState { enabled, streaming, disabled }
class PromptInputSendButton extends StatelessWidget {
const PromptInputSendButton({
super.key,
required this.state,
required this.onSendPressed,
required this.onStopStreaming,
});
final SendButtonState state;
final VoidCallback onSendPressed;
final VoidCallback onStopStreaming;
@override
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,
size: Size.square(_iconSize),
color: Theme.of(context).colorScheme.primary,
),
SendButtonState.disabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(_iconSize),
color: Theme.of(context).disabledColor,
),
SendButtonState.streaming => FlowySvg(
FlowySvgs.ai_stop_filled_s,
size: Size.square(_iconSize),
color: Theme.of(context).colorScheme.primary,
),
},
onPressed: () {
switch (state) {
case SendButtonState.enabled:
onSendPressed();
break;
case SendButtonState.streaming:
onStopStreaming();
break;
case SendButtonState.disabled:
break;
}
},
hoverColor: Colors.transparent,
);
}
double get _buttonSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.sendButtonSize
: DesktopAIPromptSizes.actionBarSendButtonSize;
}
double get _iconSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.sendButtonSize
: DesktopAIPromptSizes.actionBarSendButtonIconSize;
}
}
@@ -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 {
+6 -1
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;
@@ -180,7 +184,7 @@ Future<void> useLocalServer() async {
await _setAuthenticatorType(AuthenticatorType.local);
}
/// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
class AppFlowyCloudSharedEnv {
AppFlowyCloudSharedEnv({
required AuthenticatorType authenticatorType,
@@ -247,6 +251,7 @@ Future<AppFlowyCloudConfiguration> configurationFromUri(
// In development mode, the app is configured to access the AppFlowy cloud server directly through specific ports.
// This setup bypasses the need for Nginx, meaning that the AppFlowy cloud should be running without an Nginx server
// in the development environment.
// If you modify following code, please update the corresponding documentation in the appflowy billing.
if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) {
return AppFlowyCloudConfiguration(
base_url: "$baseUrl:8000",
@@ -502,11 +502,11 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
// Simulate the focused state because the text field should always be focused
// during traversal. If the menu item has a custom foreground color, the "focused"
// color will also change to foregroundColor.withOpacity(0.12).
// color will also change to foregroundColor.withValues(alpha: 0.12).
effectiveStyle = entry.enabled && i == focusedIndex
? effectiveStyle.copyWith(
backgroundColor: WidgetStatePropertyAll<Color>(
focusedBackgroundColor.withOpacity(0.12),
focusedBackgroundColor.withValues(alpha: 0.12),
),
)
: effectiveStyle;
@@ -21,6 +21,7 @@ extension MobileRouter on BuildContext {
bool showMoreButton = true,
String? fixedTitle,
String? blockId,
List<String>? tabs,
}) async {
// set the current view before pushing the new view
getIt<MenuSharedState>().latestOpenView = view;
@@ -37,6 +38,9 @@ extension MobileRouter on BuildContext {
queryParameters[MobileDocumentScreen.viewBlockId] = blockId;
}
}
if (tabs != null) {
queryParameters[MobileDocumentScreen.viewSelectTabs] = tabs.join('-');
}
final uri = Uri(
path: view.routeName,
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
@@ -7,9 +8,11 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
@@ -17,6 +20,9 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -34,6 +40,7 @@ class MobileViewPage extends StatefulWidget {
this.fixedTitle,
this.showMoreButton = true,
this.blockId,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
/// view id
@@ -43,6 +50,7 @@ class MobileViewPage extends StatefulWidget {
final Map<String, dynamic>? arguments;
final bool showMoreButton;
final String? blockId;
final List<PickerTabType> tabs;
// only used in row page
final String? fixedTitle;
@@ -88,7 +96,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
final body = _buildBody(context, state);
if (view == null) {
return _buildApp(context, null, body);
return SizedBox.shrink();
}
return MultiBlocProvider(
@@ -119,6 +127,11 @@ class _MobileViewPageState extends State<MobileViewPage> {
create: (_) => DocumentPageStyleBloc(view: view)
..add(const DocumentPageStyleEvent.initial()),
),
if (view.layout.isDocumentView || view.layout.isDatabaseView)
BlocProvider(
create: (_) => ViewLockStatusBloc(view: view)
..add(const ViewLockStatusEvent.initial()),
),
],
child: Builder(
builder: (context) {
@@ -149,6 +162,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
title: title,
appBarOpacity: _appBarOpacity,
actions: actions,
view: view,
)
: FlowyAppBar(title: title, actions: actions);
final body = isDocument
@@ -193,6 +207,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
data: {
MobileDocumentScreen.viewFixedTitle: widget.fixedTitle,
MobileDocumentScreen.viewBlockId: widget.blockId,
MobileDocumentScreen.viewSelectTabs: widget.tabs,
},
);
},
@@ -218,6 +233,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
final isImmersiveMode =
context.read<MobileViewPageBloc>().state.isImmersiveMode;
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final actions = <Widget>[];
if (FeatureFlag.syncDocument.isOn) {
@@ -236,12 +253,13 @@ class _MobileViewPageState extends State<MobileViewPage> {
}
}
if (view.layout.isDocumentView) {
if (view.layout.isDocumentView && !isLocked) {
actions.addAll([
MobileViewPageLayoutButton(
view: view,
isImmersiveMode: isImmersiveMode,
appBarOpacity: _appBarOpacity,
tabs: widget.tabs,
),
]);
}
@@ -265,25 +283,139 @@ class _MobileViewPageState extends State<MobileViewPage> {
Widget _buildTitle(BuildContext context, ViewPB? view) {
final icon = view?.icon;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null && icon.value.isNotEmpty) ...[
RawEmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
return ValueListenableBuilder(
valueListenable: _appBarOpacity,
builder: (_, value, child) {
if (value < 0.99) {
return Padding(
padding: const EdgeInsets.only(left: 6.0),
child: _buildLockStatus(context, view),
);
}
final name =
widget.fixedTitle ?? view?.nameOrDefault ?? widget.title ?? '';
return Opacity(
opacity: value,
child: Row(
children: [
if (icon != null && icon.value.isNotEmpty) ...[
RawEmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
),
const HSpace(4),
],
Flexible(
child: FlowyText.medium(
name,
fontSize: 15.0,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 18.0,
),
),
const HSpace(4.0),
_buildLockStatusIcon(context, view),
],
),
const HSpace(4),
],
Expanded(
child: FlowyText.medium(
widget.fixedTitle ?? view?.name ?? widget.title ?? '',
fontSize: 15.0,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 18.0,
),
),
],
);
},
);
}
Widget _buildLockStatus(BuildContext context, ViewPB? view) {
if (view == null || view.layout == ViewLayoutPB.Chat) {
return const SizedBox.shrink();
}
return BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (previous, current) =>
previous.isLoadingLockStatus == current.isLoadingLockStatus &&
current.isLoadingLockStatus == false,
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
EditorNotification.exitEditing().post();
}
},
builder: (context, state) {
if (state.isLocked) {
return LockedPageStatus();
} else if (!state.isLocked && state.lockCounter > 0) {
return ReLockedPageStatus();
}
return const SizedBox.shrink();
},
);
}
Widget _buildLockStatusIcon(BuildContext context, ViewPB? view) {
if (view == null || view.layout == ViewLayoutPB.Chat) {
return const SizedBox.shrink();
}
return BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (previous, current) =>
previous.isLoadingLockStatus == current.isLoadingLockStatus &&
current.isLoadingLockStatus == false,
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}
},
builder: (context, state) {
if (state.isLocked) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
context.read<ViewLockStatusBloc>().add(
const ViewLockStatusEvent.unlock(),
);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
right: 8,
bottom: 4.0,
),
child: FlowySvg(
FlowySvgs.lock_page_fill_s,
blendMode: null,
),
),
);
} else if (!state.isLocked && state.lockCounter > 0) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
context.read<ViewLockStatusBloc>().add(
const ViewLockStatusEvent.lock(),
);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
right: 8,
bottom: 4.0,
),
child: FlowySvg(
FlowySvgs.unlock_page_s,
color: Color(0xFF8F959E),
blendMode: null,
),
),
);
}
return const SizedBox.shrink();
},
);
}
@@ -9,8 +9,10 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -27,12 +29,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
required this.appBarOpacity,
required this.title,
required this.actions,
required this.view,
});
final ValueListenable appBarOpacity;
final Widget title;
final List<Widget> actions;
final ViewPB? view;
@override
final Size preferredSize;
@@ -42,9 +45,9 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
valueListenable: appBarOpacity,
builder: (_, opacity, __) => FlowyAppBar(
backgroundColor:
AppBarTheme.of(context).backgroundColor?.withOpacity(opacity),
AppBarTheme.of(context).backgroundColor?.withValues(alpha: opacity),
showDivider: false,
title: Opacity(opacity: opacity >= 0.99 ? 1.0 : 0, child: title),
title: _buildTitle(context, opacity: opacity),
leadingWidth: 44,
leading: Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0),
@@ -55,6 +58,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
);
}
Widget _buildTitle(
BuildContext context, {
required double opacity,
}) {
return title;
}
Widget _buildAppBarBackButton(BuildContext context) {
return AppBarButton(
padding: EdgeInsets.zero,
@@ -101,6 +111,12 @@ class MobileViewPageMoreButton extends StatelessWidget {
BlocProvider.value(value: context.read<FavoriteBloc>()),
BlocProvider.value(value: context.read<MobileViewPageBloc>()),
BlocProvider.value(value: context.read<ShareBloc>()),
BlocProvider(
create: (context) => ViewLockStatusBloc(view: view)
..add(
ViewLockStatusEvent.initial(),
),
),
],
child: MobileViewPageMoreBottomSheet(view: view),
),
@@ -123,9 +139,11 @@ class MobileViewPageLayoutButton extends StatelessWidget {
required this.view,
required this.isImmersiveMode,
required this.appBarOpacity,
required this.tabs,
});
final ViewPB view;
final List<PickerTabType> tabs;
final bool isImmersiveMode;
final ValueListenable appBarOpacity;
@@ -156,6 +174,7 @@ class MobileViewPageLayoutButton extends StatelessWidget {
],
child: PageStyleBottomSheet(
view: context.read<ViewBloc>().state.view,
tabs: tabs,
),
),
);
@@ -220,7 +239,7 @@ class _ImmersiveAppBarButton extends StatelessWidget {
child = DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimension / 2.0),
color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
),
child: child,
);
@@ -14,6 +14,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -43,7 +44,8 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
},
child: ViewPageBottomSheet(
view: view,
onAction: (action) async => _onAction(context, action),
onAction: (action, {arguments}) async =>
_onAction(context, action, arguments),
onRename: (name) {
_onRename(context, name);
context.pop();
@@ -56,6 +58,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
Future<void> _onAction(
BuildContext context,
MobileViewBottomSheetBodyAction action,
Map<String, dynamic>? arguments,
) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
@@ -107,12 +110,32 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
break;
case MobileViewBottomSheetBodyAction.updatePathName:
_updatePathName(context);
case MobileViewBottomSheetBodyAction.lockPage:
final isLocked =
arguments?[MobileViewBottomSheetBodyActionArguments.isLockedKey] ??
false;
await _lockPage(context, isLocked: isLocked);
// context.pop();
break;
case MobileViewBottomSheetBodyAction.rename:
// no need to implement, rename is handled by the onRename callback.
throw UnimplementedError();
}
}
Future<void> _lockPage(
BuildContext context, {
required bool isLocked,
}) async {
if (isLocked) {
context.read<ViewLockStatusBloc>().add(const ViewLockStatusEvent.lock());
} else {
context
.read<ViewLockStatusBloc>()
.add(const ViewLockStatusEvent.unlock());
}
}
Future<void> _publish(BuildContext context) async {
final id = context.read<ShareBloc>().view.id;
final lastPublishName = context.read<ShareBloc>().state.pathName;
@@ -1,8 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum MobileViewItemBottomSheetBodyAction {
rename,
@@ -40,6 +42,8 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
BuildContext context,
MobileViewItemBottomSheetBodyAction action,
) {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
switch (action) {
case MobileViewItemBottomSheetBodyAction.rename:
return FlowyOptionTile.text(
@@ -49,6 +53,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
FlowySvgs.view_item_rename_s,
size: Size.square(18),
),
enable: !isLocked,
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
@@ -94,6 +99,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
size: const Size.square(18),
color: Theme.of(context).colorScheme.error,
),
enable: !isLocked,
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
@@ -4,9 +4,12 @@ import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.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/shared/share/share_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -25,11 +28,27 @@ enum MobileViewBottomSheetBodyAction {
visitSite,
copyShareLink,
updatePathName,
lockPage;
static const disableInLockedView = [
undo,
redo,
rename,
delete,
];
}
class MobileViewBottomSheetBodyActionArguments {
static const isLockedKey = 'is_locked';
}
typedef MobileViewBottomSheetBodyActionCallback = void Function(
MobileViewBottomSheetBodyAction action,
);
// for the [MobileViewBottomSheetBodyAction.lockPage] action,
// it will pass the [isLocked] value to the callback.
{
Map<String, dynamic>? arguments,
});
class ViewPageBottomSheet extends StatefulWidget {
const ViewPageBottomSheet({
@@ -56,7 +75,7 @@ class _ViewPageBottomSheetState extends State<ViewPageBottomSheet> {
case MobileBottomSheetType.view:
return MobileViewBottomSheetBody(
view: widget.view,
onAction: (action) {
onAction: (action, {arguments}) {
switch (action) {
case MobileViewBottomSheetBodyAction.rename:
setState(() {
@@ -64,7 +83,7 @@ class _ViewPageBottomSheetState extends State<ViewPageBottomSheet> {
});
break;
default:
widget.onAction(action);
widget.onAction(action, arguments: arguments);
}
},
);
@@ -93,6 +112,8 @@ class MobileViewBottomSheetBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isFavorite = view.isFavorite;
final isLocked =
context.watch<ViewLockStatusBloc?>()?.state.isLocked ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -100,6 +121,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
text: LocaleKeys.button_rename.tr(),
icon: FlowySvgs.view_item_rename_s,
iconSize: const Size.square(18),
enable: !isLocked,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.rename,
),
@@ -118,6 +140,28 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
),
_divider(),
if (view.layout.isDatabaseView || view.layout.isDocumentView) ...[
MobileQuickActionButton(
text: LocaleKeys.disclosureAction_lockPage.tr(),
icon: FlowySvgs.lock_page_s,
iconSize: const Size.square(18),
rightIconBuilder: (context) => _LockPageRightIconBuilder(
onAction: onAction,
),
onTap: () {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
onAction(
MobileViewBottomSheetBodyAction.lockPage,
arguments: {
MobileViewBottomSheetBodyActionArguments.isLockedKey:
!isLocked,
},
);
},
),
_divider(),
],
MobileQuickActionButton(
text: LocaleKeys.button_duplicate.tr(),
icon: FlowySvgs.duplicate_s,
@@ -138,12 +182,14 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
icon: FlowySvgs.trash_s,
iconColor: Theme.of(context).colorScheme.error,
iconSize: const Size.square(18),
enable: !isLocked,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.delete,
),
@@ -206,3 +252,36 @@ class MobileViewBottomSheetBody extends StatelessWidget {
Widget _divider() => const MobileQuickActionDivider();
}
class _LockPageRightIconBuilder extends StatelessWidget {
const _LockPageRightIconBuilder({
required this.onAction,
});
final MobileViewBottomSheetBodyActionCallback onAction;
@override
Widget build(BuildContext context) {
final isLocked =
context.watch<ViewLockStatusBloc?>()?.state.isLocked ?? false;
return SizedBox(
width: 46,
height: 30,
child: FittedBox(
fit: BoxFit.fill,
child: CupertinoSwitch(
value: isLocked,
activeTrackColor: Theme.of(context).colorScheme.primary,
onChanged: (value) {
onAction(
MobileViewBottomSheetBodyAction.lockPage,
arguments: {
MobileViewBottomSheetBodyActionArguments.isLockedKey: value,
},
);
},
),
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -131,6 +132,11 @@ enum MobilePaneActionType {
BlocProvider.value(value: favoriteBloc),
if (recentViewsBloc != null)
BlocProvider.value(value: recentViewsBloc),
BlocProvider(
create: (_) =>
ViewLockStatusBloc(view: viewBloc.state.view)
..add(const ViewLockStatusEvent.initial()),
),
],
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) {
@@ -74,7 +74,7 @@ Future<T?> showMobileBottomSheet<T>(
backgroundColor ??= Theme.of(context).brightness == Brightness.light
? const Color(0xFFF7F8FB)
: const Color(0xFF23262B);
barrierColor ??= Colors.black.withOpacity(0.3);
barrierColor ??= Colors.black.withValues(alpha: 0.3);
return showModalBottomSheet<T>(
context: context,
@@ -329,7 +329,7 @@ class CupertinoSheetBottomRouteTransition extends StatelessWidget {
(Theme.of(context).brightness == Brightness.dark
? Colors.grey
: Colors.black)
.withOpacity(secondaryAnimation.value * 0.1),
.withValues(alpha: secondaryAnimation.value * 0.1),
BlendMode.srcOver,
),
child: child,
@@ -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/database/board/board.dart';
@@ -13,12 +11,14 @@ import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobi
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_board/appflowy_board.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:go_router/go_router.dart';
@@ -143,6 +143,8 @@ class _BoardContentState extends State<_BoardContent> {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final isLocked =
context.watch<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final showCreateGroupButton = context
.read<BoardBloc>()
.groupingFieldType
@@ -160,15 +162,20 @@ class _BoardContentState extends State<_BoardContent> {
padding: config.groupHeaderPadding,
)
: const HSpace(16),
trailing: showCreateGroupButton
trailing: showCreateGroupButton && !isLocked
? const MobileBoardTrailing()
: const HSpace(16),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: GroupCardHeader(
groupData: groupData,
),
),
headerBuilder: (_, groupData) {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ??
false;
return IgnorePointer(
ignoring: isLocked,
child: GroupCardHeader(
groupData: groupData,
),
);
},
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context: context,
@@ -184,34 +191,39 @@ class _BoardContentState extends State<_BoardContent> {
}
Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final style = Theme.of(context);
return SizedBox(
height: 42,
width: double.infinity,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.only(left: 8),
alignment: Alignment.centerLeft,
),
icon: FlowySvg(
FlowySvgs.add_m,
color: style.colorScheme.onSurface,
),
label: Text(
LocaleKeys.board_column_createNewCard.tr(),
style: style.textTheme.bodyMedium?.copyWith(
child: IgnorePointer(
ignoring: isLocked,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.only(left: 8),
alignment: Alignment.centerLeft,
),
icon: FlowySvg(
FlowySvgs.add_m,
color: style.colorScheme.onSurface,
),
),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createRow(
columnData.id,
OrderObjectPositionTypePB.End,
null,
null,
),
label: Text(
LocaleKeys.board_column_createNewCard.tr(),
style: style.textTheme.bodyMedium?.copyWith(
color: style.colorScheme.onSurface,
),
),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createRow(
columnData.id,
OrderObjectPositionTypePB.End,
null,
null,
),
),
),
),
);
}
@@ -231,6 +243,8 @@ class _BoardContentState extends State<_BoardContent> {
CardCellBuilder(databaseController: boardBloc.databaseController);
final groupItemId = groupItem.row.id + groupData.group.groupId;
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
return Container(
key: ValueKey(groupItemId),
@@ -238,31 +252,34 @@ class _BoardContentState extends State<_BoardContent> {
decoration: _makeBoxDecoration(context),
child: BlocProvider.value(
value: boardBloc,
child: RowCard(
fieldController: boardBloc.fieldController,
rowMeta: rowMeta,
viewId: boardBloc.viewId,
rowCache: boardBloc.rowCache,
groupingFieldId: groupItem.fieldInfo.id,
isEditing: false,
cellBuilder: cellBuilder,
onTap: (context) {
context.push(
MobileRowDetailPage.routeName,
extra: {
MobileRowDetailPage.argRowId: rowMeta.id,
MobileRowDetailPage.argDatabaseController:
context.read<BoardBloc>().databaseController,
},
);
},
onStartEditing: () {},
onEndEditing: () {},
styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: mobileBoardCardCellStyleMap(context),
showAccessory: false,
child: IgnorePointer(
ignoring: isLocked,
child: RowCard(
fieldController: boardBloc.fieldController,
rowMeta: rowMeta,
viewId: boardBloc.viewId,
rowCache: boardBloc.rowCache,
groupingFieldId: groupItem.fieldInfo.id,
isEditing: false,
cellBuilder: cellBuilder,
onTap: (context) {
context.push(
MobileRowDetailPage.routeName,
extra: {
MobileRowDetailPage.argRowId: rowMeta.id,
MobileRowDetailPage.argDatabaseController:
context.read<BoardBloc>().databaseController,
},
);
},
onStartEditing: () {},
onEndEditing: () {},
styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: mobileBoardCardCellStyleMap(context),
showAccessory: false,
),
userProfile: boardBloc.userProfile,
),
userProfile: boardBloc.userProfile,
),
),
);
@@ -276,14 +293,20 @@ class _BoardContentState extends State<_BoardContent> {
border: themeMode == ThemeMode.light
? Border.fromBorderSide(
BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
)
: null,
boxShadow: themeMode == ThemeMode.light
? [
BoxShadow(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -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,
@@ -8,6 +8,7 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
@@ -116,6 +117,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
addInRecent: false,
showMoreButton: false,
fixedTitle: fieldName,
tabs: [PickerTabType.emoji.name],
);
}
}
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart';
@@ -8,6 +6,7 @@ import 'package:appflowy/plugins/database/domain/field_backend_service.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MobileEditPropertyScreen extends StatefulWidget {
@@ -49,7 +48,7 @@ class _MobileEditPropertyScreenState extends State<MobileEditPropertyScreen> {
final fieldId = widget.field.id;
return PopScope(
onPopInvoked: (didPop) {
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
context.pop(_fieldOptionValues);
}
@@ -5,6 +5,8 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
@@ -163,9 +165,20 @@ class MobileDatabaseViewListButton extends StatelessWidget {
}
Widget _buildViewIconButton(BuildContext context, ViewPB view) {
final iconData = view.icon.toEmojiIconData();
Widget icon;
if (iconData.isEmpty || iconData.type != FlowyIconType.icon) {
icon = view.defaultIcon();
} else {
icon = RawEmojiIconWidget(
emoji: iconData,
emojiSize: 14.0,
enableColor: false,
);
}
return SizedBox.square(
dimension: 20.0,
child: view.defaultIcon(),
child: icon,
);
}
@@ -1,11 +1,16 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/database/application/database_controller.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_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -49,6 +54,45 @@ class MobileDatabaseViewQuickActions extends StatelessWidget {
}
}),
const MobileQuickActionDivider(),
_actionButton(
context,
_Action.changeIcon,
() {
showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
showHeader: true,
title: LocaleKeys.titleBar_pageIcon.tr(),
backgroundColor: AFThemeExtension.of(context).background,
enableDraggableScrollable: true,
minChildSize: 0.6,
initialChildSize: 0.61,
scrollableWidgetBuilder: (_, controller) {
return Expanded(
child: FlowyIconEmojiPicker(
tabs: const [PickerTabType.icon],
enableBackgroundColorSelection: false,
onSelectedEmoji: (r) {
ViewBackendService.updateViewIcon(
view: view,
viewIcon: r.data,
);
Navigator.pop(context);
},
),
);
},
builder: (_) => const SizedBox.shrink(),
).then((_) {
if (context.mounted) {
Navigator.pop(context);
}
});
},
!isInline,
),
const MobileQuickActionDivider(),
_actionButton(
context,
_Action.duplicate,
@@ -91,14 +135,16 @@ class MobileDatabaseViewQuickActions extends StatelessWidget {
enum _Action {
edit,
duplicate,
delete;
changeIcon,
delete,
duplicate;
String get label {
return switch (this) {
edit => LocaleKeys.grid_settings_editView.tr(),
duplicate => LocaleKeys.button_duplicate.tr(),
delete => LocaleKeys.button_delete.tr(),
changeIcon => LocaleKeys.disclosureAction_changeIcon.tr(),
};
}
@@ -107,6 +153,7 @@ enum _Action {
edit => FlowySvgs.view_item_rename_s,
duplicate => FlowySvgs.duplicate_s,
delete => FlowySvgs.trash_s,
changeIcon => FlowySvgs.change_icon_s,
};
}
@@ -1,4 +1,5 @@
import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
@@ -10,6 +11,7 @@ class MobileDocumentScreen extends StatelessWidget {
this.showMoreButton = true,
this.fixedTitle,
this.blockId,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
/// view id
@@ -18,6 +20,7 @@ class MobileDocumentScreen extends StatelessWidget {
final bool showMoreButton;
final String? fixedTitle;
final String? blockId;
final List<PickerTabType> tabs;
static const routeName = '/docs';
static const viewId = 'id';
@@ -25,6 +28,7 @@ class MobileDocumentScreen extends StatelessWidget {
static const viewShowMoreButton = 'show_more_button';
static const viewFixedTitle = 'fixed_title';
static const viewBlockId = 'block_id';
static const viewSelectTabs = 'select_tabs';
@override
Widget build(BuildContext context) {
@@ -35,6 +39,7 @@ class MobileDocumentScreen extends StatelessWidget {
showMoreButton: showMoreButton,
fixedTitle: fixedTitle,
blockId: blockId,
tabs: tabs,
);
}
}
@@ -3,16 +3,19 @@ import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/setting/ai/ai_settings_group.dart';
import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart';
import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/workspace_setting_group.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileHomeSettingPage extends StatefulWidget {
const MobileHomeSettingPage({
@@ -70,29 +73,46 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
Widget _buildSettingsWidget(UserProfilePB userProfile) {
// show the third-party sign in buttons if user logged in with local session and auth is enabled.
final showThirdPartyLogin =
final isLocalAuthEnabled =
userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
PersonalInfoSettingGroup(
userProfile: userProfile,
'';
return BlocProvider(
create: (context) => UserWorkspaceBloc(userProfile: userProfile)
..add(const UserWorkspaceEvent.initial()),
child: BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final currentWorkspaceId = state.currentWorkspace?.workspaceId ?? '';
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
PersonalInfoSettingGroup(
userProfile: userProfile,
),
const WorkspaceSettingGroup(),
const AppearanceSettingGroup(),
const LanguageSettingGroup(),
if (Env.enableCustomCloud) const CloudSettingGroup(),
if (isAuthEnabled)
AiSettingsGroup(
key: ValueKey(currentWorkspaceId),
userProfile: userProfile,
workspaceId: currentWorkspaceId,
),
const SupportSettingGroup(),
const AboutSettingGroup(),
UserSessionSettingGroup(
userProfile: userProfile,
showThirdPartyLogin: isLocalAuthEnabled,
),
const VSpace(20),
],
),
),
const WorkspaceSettingGroup(),
const AppearanceSettingGroup(),
const LanguageSettingGroup(),
if (Env.enableCustomCloud) const CloudSettingGroup(),
const SupportSettingGroup(),
const AboutSettingGroup(),
UserSessionSettingGroup(
userProfile: userProfile,
showThirdPartyLogin: showThirdPartyLogin,
),
const VSpace(20),
],
),
);
},
),
);
}
@@ -212,7 +212,7 @@ class _DeletedFilesListView extends StatelessWidget {
?.copyWith(color: theme.colorScheme.onSurface),
),
horizontalTitleGap: 0,
tileColor: theme.colorScheme.onSurface.withOpacity(0.1),
tileColor: theme.colorScheme.onSurface.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
@@ -134,7 +134,8 @@ class _RecentCover extends StatelessWidget {
Widget build(BuildContext context) {
final placeholder = Container(
// random color, update it once we have a better placeholder
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.2),
color:
Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
);
final value = this.value;
if (value == null) {
@@ -3,6 +3,7 @@ import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -101,7 +102,14 @@ class _Pages extends StatelessWidget {
level: 0,
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
onSelected: (v) => context.pushView(
v,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
),
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
return buildEndActionPane(
@@ -11,6 +11,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
@@ -81,7 +82,14 @@ class MobileViewPage extends StatelessWidget {
spaceRatio: 4,
),
child: AnimatedGestureDetector(
onTapUp: () => context.pushView(view),
onTapUp: () => context.pushView(
view,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -140,7 +148,8 @@ class MobileViewPage extends StatelessWidget {
final iconUrl = userProfile?.iconUrl;
if (iconUrl == null ||
iconUrl.isEmpty ||
view.createdBy != userProfile?.id) {
view.createdBy != userProfile?.id ||
!isURL(iconUrl)) {
return const SizedBox.shrink();
}
@@ -182,7 +191,7 @@ class MobileViewPage extends StatelessWidget {
WidgetSpan(
child: SizedBox(
width: 20,
child: EmojiIconWidget(
child: RawEmojiIconWidget(
emoji: icon,
emojiSize: 18.0,
),
@@ -206,7 +215,7 @@ class MobileViewPage extends StatelessWidget {
Widget _buildAuthor(BuildContext context, RecentViewState state) {
return FlowyText.regular(
// view.createdBy.toString(),
'Lucas',
'',
fontSize: 12.0,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
@@ -216,7 +225,7 @@ class MobileViewPage extends StatelessWidget {
Widget _buildLastViewed(BuildContext context) {
final textColor = Theme.of(context).isLightMode
? const Color(0x7F171717)
: Colors.white.withOpacity(0.45);
: Colors.white.withValues(alpha: 0.45);
if (timestamp == null) {
return const SizedBox.shrink();
}
Loaded 100 of 793 files, more files were not shown because too many files have changed in this diff. Show more