Compare commits
316
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5946e75c | ||
|
|
f28a299786 | ||
|
|
4b0d61b534 | ||
|
|
cf8fb19976 | ||
|
|
1525a363f2 | ||
|
|
5a99da9a45 | ||
|
|
495226fa2c | ||
|
|
2e295e6891 | ||
|
|
351c891a5a | ||
|
|
bcb1e8e4f5 | ||
|
|
4997ac99cf | ||
|
|
e3bbabd63e | ||
|
|
403c558f9b | ||
|
|
31d2c1d603 | ||
|
|
a7adeeadb1 | ||
|
|
f7d7141a59 | ||
|
|
2371d4691d | ||
|
|
9ec0437673 | ||
|
|
89525b7f7b | ||
|
|
ad227bcf79 | ||
|
|
8b82d532e0 | ||
|
|
f4af47728f | ||
|
|
b3a4a9ba04 | ||
|
|
13028c6cfe | ||
|
|
7d0f6c9deb | ||
|
|
8d2901cc58 | ||
|
|
bbc7b6d172 | ||
|
|
145d1e5fdb | ||
|
|
7ffd459875 | ||
|
|
eae4e42dcc | ||
|
|
6886261692 | ||
|
|
23f2d85e70 | ||
|
|
d1d598940d | ||
|
|
efb98d28ef | ||
|
|
462c822255 | ||
|
|
9e30b1816f | ||
|
|
d79929d1c9 | ||
|
|
0286678286 | ||
|
|
4896d7c1be | ||
|
|
b06a2337a0 | ||
|
|
20bcdd1f90 | ||
|
|
24d57336a9 | ||
|
|
91397c963a | ||
|
|
995b773c74 | ||
|
|
c561abd9f8 | ||
|
|
10dd0fa438 | ||
|
|
d74b0bf6e1 | ||
|
|
913924d8d3 | ||
|
|
7bc9ce4391 | ||
|
|
ed5334a7d6 | ||
|
|
120f22c6fc | ||
|
|
f8a17dac00 | ||
|
|
7f41feb959 | ||
|
|
da7f584da7 | ||
|
|
031a88f4c4 | ||
|
|
e2ff12415c | ||
|
|
edd59cf462 | ||
|
|
b52f681e3c | ||
|
|
5d33d723e9 | ||
|
|
5e1a8b1ec7 | ||
|
|
3c99105b23 | ||
|
|
0b298ea426 | ||
|
|
7ed36f8736 | ||
|
|
34d2b7f24e | ||
|
|
a2303d35e8 | ||
|
|
c87d9ab74f | ||
|
|
e3a0806eee | ||
|
|
b63c4dfe21 | ||
|
|
2277d7d234 | ||
|
|
f76ce2be14 | ||
|
|
3c74208ab9 | ||
|
|
5ae3f42313 | ||
|
|
af5c4bfe76 | ||
|
|
9c25769d8e | ||
|
|
12a4bf67f6 | ||
|
|
34a858e948 | ||
|
|
3a879b0186 | ||
|
|
dbe72b32b2 | ||
|
|
917aa60c98 | ||
|
|
84e0f5e6ff | ||
|
|
7b89d76cea | ||
|
|
fbf031b06d | ||
|
|
671e855b0e | ||
|
|
8aa32ca3fa | ||
|
|
07c767c4fa | ||
|
|
4ce0782a05 | ||
|
|
7456c65799 | ||
|
|
8cf31b8afc | ||
|
|
ff70595a15 | ||
|
|
f6f19a0a07 | ||
|
|
b83b964678 | ||
|
|
f574b6b9c2 | ||
|
|
7ee29dcbc5 | ||
|
|
d348361889 | ||
|
|
07a78b4ad7 | ||
|
|
a26ebbccc1 | ||
|
|
ccb020e885 | ||
|
|
76cb23e233 | ||
|
|
4686e13390 | ||
|
|
584f762e11 | ||
|
|
8528811992 | ||
|
|
9147f64b65 | ||
|
|
f7f2e71ee1 | ||
|
|
f9e1dcca6c | ||
|
|
e11388491f | ||
|
|
eb0cff36c9 | ||
|
|
ac8141ab15 | ||
|
|
b3b13e550d | ||
|
|
ba1767e312 | ||
|
|
10048dadec | ||
|
|
815bb11cde | ||
|
|
7372f5583c | ||
|
|
9115e208ac | ||
|
|
24bb1b58a0 | ||
|
|
cfca70ae14 | ||
|
|
1db6da7024 | ||
|
|
c212c568e9 | ||
|
|
1d437fb81e | ||
|
|
5a0478ad56 | ||
|
|
039c191d1f | ||
|
|
eb4b015de8 | ||
|
|
5269bfbf8e | ||
|
|
ed44c20281 | ||
|
|
878323a299 | ||
|
|
0dc2363962 | ||
|
|
72d660f1ac | ||
|
|
46532a861f | ||
|
|
4c39908748 | ||
|
|
35081fd311 | ||
|
|
7463e4e3eb | ||
|
|
66ce786726 | ||
|
|
682a50da53 | ||
|
|
d372abd5a1 | ||
|
|
dfb5a6629f | ||
|
|
bb72f7e70a | ||
|
|
37085042f8 | ||
|
|
2cbcb320fe | ||
|
|
949556e2fa | ||
|
|
08d1d3602e | ||
|
|
910c45e457 | ||
|
|
6f031d0c7e | ||
|
|
44c9d572c8 | ||
|
|
05949d2f87 | ||
|
|
ad695e43b9 | ||
|
|
87015f7133 | ||
|
|
deb019aa4a | ||
|
|
c79d014305 | ||
|
|
182101023b | ||
|
|
f1b2f51a06 | ||
|
|
a5c0ad5998 | ||
|
|
6fd250d4d1 | ||
|
|
db2270c8d8 | ||
|
|
10f19069c6 | ||
|
|
13a7ea07a8 | ||
|
|
aacd795ae0 | ||
|
|
566e7b2f40 | ||
|
|
f413b9e070 | ||
|
|
6e4206a8e2 | ||
|
|
954aa48f52 | ||
|
|
461ac91b32 | ||
|
|
8a9cc278ec | ||
|
|
9db87944f2 | ||
|
|
9230981e54 | ||
|
|
72b13dd941 | ||
|
|
e6b0c8ff05 | ||
|
|
f0d967f0e4 | ||
|
|
85b9aab015 | ||
|
|
69571f668c | ||
|
|
a89dd87c16 | ||
|
|
22b03eee29 | ||
|
|
e3ea3fcdfa | ||
|
|
ccfbde9a92 | ||
|
|
7358860bfc | ||
|
|
17b355197c | ||
|
|
aa7e50cc6c | ||
|
|
69ce105806 | ||
|
|
cafdfcca51 | ||
|
|
a8c5c9c34e | ||
|
|
6dd83675fc | ||
|
|
b65fad6214 | ||
|
|
884046ba3f | ||
|
|
6d327adb83 | ||
|
|
eddb623fba | ||
|
|
2ea8e831cd | ||
|
|
270c981051 | ||
|
|
7971566159 | ||
|
|
9fbf2d5ef6 | ||
|
|
21bf2968a9 | ||
|
|
117950c922 | ||
|
|
b4c56f7998 | ||
|
|
af0c802486 | ||
|
|
33d518f383 | ||
|
|
1f9fe89f87 | ||
|
|
5fef4f1d49 | ||
|
|
d6446872ee | ||
|
|
5b5feb2515 | ||
|
|
b1bca1b55b | ||
|
|
4e1a70c7ac | ||
|
|
54f25e4b91 | ||
|
|
aa176f2c12 | ||
|
|
58895620c1 | ||
|
|
e10aade895 | ||
|
|
1fdd7c343b | ||
|
|
e36b08cd14 | ||
|
|
6ac9ad1cac | ||
|
|
36bf90e81b | ||
|
|
22b9acf386 | ||
|
|
e4e75acdac | ||
|
|
7996736592 | ||
|
|
3ad8f624cf | ||
|
|
0657aeb07d | ||
|
|
723971e423 | ||
|
|
86b67a1b65 | ||
|
|
d94b4daa70 | ||
|
|
ad62e85b3a | ||
|
|
133eec8163 | ||
|
|
81bac5950c | ||
|
|
651046ab68 | ||
|
|
9bd13ac29e | ||
|
|
c7d3d612ae | ||
|
|
69dd2ab20f | ||
|
|
f0b8b00461 | ||
|
|
2b8aaf1d46 | ||
|
|
ee69283a23 | ||
|
|
caaf5f7986 | ||
|
|
392964ffd2 | ||
|
|
1f76412790 | ||
|
|
555254e8fe | ||
|
|
3aa55f83b1 | ||
|
|
d15a8a88a6 | ||
|
|
1f7ab9d22d | ||
|
|
44945b2912 | ||
|
|
8d50caa86e | ||
|
|
b59eba76a6 | ||
|
|
070cde9ecb | ||
|
|
1e81e4c68f | ||
|
|
402ca7d765 | ||
|
|
d0ca7f311c | ||
|
|
e553627ee5 | ||
|
|
cbdac71025 | ||
|
|
6f35ae9857 | ||
|
|
20d64cc7ae | ||
|
|
654e18aacf | ||
|
|
75dd5c1d93 | ||
|
|
f8f9c3404a | ||
|
|
01e5817b24 | ||
|
|
96608bd005 | ||
|
|
57a5b38509 | ||
|
|
83c53188e3 | ||
|
|
6ba7f93f69 | ||
|
|
702a486cce | ||
|
|
eb0ed1ad86 | ||
|
|
e264b3a5b8 | ||
|
|
667d15c627 | ||
|
|
bd06e1d559 | ||
|
|
459aca5291 | ||
|
|
e7cd90b6ab | ||
|
|
940db70447 | ||
|
|
59139ff323 | ||
|
|
22fed1bfbc | ||
|
|
5e593bd36e | ||
|
|
ba1dfc6de4 | ||
|
|
c81f87dcdc | ||
|
|
a8b55ca3f0 | ||
|
|
0cefaf633c | ||
|
|
ba4aebd005 | ||
|
|
7b32a92290 | ||
|
|
41b99209f1 | ||
|
|
c1612fe298 | ||
|
|
e69a09d332 | ||
|
|
4e0d9fdb0b | ||
|
|
8b2e769fca | ||
|
|
d29a90a472 | ||
|
|
2e4beb0652 | ||
|
|
addb041816 | ||
|
|
4ff71b5dce | ||
|
|
a0ae62d6f5 | ||
|
|
ea18aa7551 | ||
|
|
68e7069e92 | ||
|
|
556d929b67 | ||
|
|
7f3469a0f2 | ||
|
|
a062c4aadb | ||
|
|
3d3f81ad52 | ||
|
|
fc0fb0b3d3 | ||
|
|
884586f0af | ||
|
|
f8c18afbcf | ||
|
|
8046177d84 | ||
|
|
8d8fc91391 | ||
|
|
796fda159e | ||
|
|
4b2389dafd | ||
|
|
2dd7e5937f | ||
|
|
e9371029f3 | ||
|
|
bbec60ff02 | ||
|
|
3bf4f080c5 | ||
|
|
637c043f5b | ||
|
|
9eed993421 | ||
|
|
aff720c1f1 | ||
|
|
655de30df5 | ||
|
|
fe6217bd82 | ||
|
|
eacd7b2503 | ||
|
|
2e17fb9dd3 | ||
|
|
249543d64f | ||
|
|
8ebd490260 | ||
|
|
c0dfec8b34 | ||
|
|
56a023c98a | ||
|
|
adcac881a7 | ||
|
|
f73342d902 | ||
|
|
45b0233c21 | ||
|
|
db349519cf | ||
|
|
c760a1b1fe | ||
|
|
c5fa9039b4 | ||
|
|
7eaafc52ce | ||
|
|
63239893ab | ||
|
|
e9a1a1ced0 | ||
|
|
6dc45c9830 | ||
|
|
58f7659d55 |
No files matched your search
@@ -1,4 +1,52 @@
|
||||
# Release Notes
|
||||
## Version 0.8.8 - 01/04/2025
|
||||
### New Features
|
||||
- Added support for selecting AI models in AI writer
|
||||
- Revamped link menu in toolbar
|
||||
- Added support for using ":" to add emojis in documents
|
||||
- Passed the history of past AI prompts and responses to AI writer
|
||||
### Bug Fixes
|
||||
- Improved AI writer scrolling user experience
|
||||
- Fixed issue where checklist items would disappear during reordering
|
||||
- Fixed numbered lists generated by AI to maintain the same index as the input
|
||||
|
||||
## Version 0.8.7 - 18/03/2025
|
||||
### New Features
|
||||
- Made local AI free and integrated with Ollama
|
||||
- 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
|
||||
|
||||
@@ -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)
|
||||
Vendored
+124
-124
@@ -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": "trace",
|
||||
},
|
||||
// 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]",
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
|
||||
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
|
||||
CARGO_MAKE_CRATE_NAME = "dart-ffi"
|
||||
LIB_NAME = "dart_ffi"
|
||||
APPFLOWY_VERSION = "0.8.4"
|
||||
APPFLOWY_VERSION = "0.8.9"
|
||||
FLUTTER_DESKTOP_FEATURES = "dart"
|
||||
PRODUCT_NAME = "AppFlowy"
|
||||
MACOSX_DEPLOYMENT_TARGET = "11.0"
|
||||
|
||||
@@ -4,6 +4,7 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- "packages/**/*.dart"
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,26 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEXAIBADCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0Y
|
||||
ruaTrrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7t
|
||||
J8mG4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy
|
||||
7xyw+sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL
|
||||
7iTVKiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqH
|
||||
Opf5b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdp
|
||||
qm4ZQRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFy
|
||||
JiJWYWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ
|
||||
5EhGG4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAh
|
||||
Lswu6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPh
|
||||
AsVA6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxC
|
||||
xMTpq1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIA
|
||||
Pxbd0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+V
|
||||
uix/4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/
|
||||
8WrbK13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqT
|
||||
QJg7hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19
|
||||
tKcOs6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQd
|
||||
bsCzAxp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzf
|
||||
J4v4uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6T
|
||||
jcfVWthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NCl
|
||||
WgZnixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3w
|
||||
m7NB+fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcT
|
||||
ilaNC9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkEHgIcfy0+ZHp+4MBcWSDv
|
||||
uzWeM8QmNvbP+owM+H4F7A==
|
||||
-----END PRIVATE KEY-----
|
||||
+13
-10
@@ -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
@@ -15,7 +15,6 @@ void main() {
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
|
||||
await tester.tapContinousAnotherWay();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+41
@@ -15,6 +15,7 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a database and add a linked database view
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
|
||||
|
||||
@@ -29,6 +30,11 @@ void main() {
|
||||
await tester.tapHidePropertyButton();
|
||||
tester.noFieldWithName('New field 1');
|
||||
|
||||
// create another field, New field 1 to be hidden still
|
||||
await tester.tapNewPropertyButton();
|
||||
await tester.dismissFieldEditor();
|
||||
tester.noFieldWithName('New field 1');
|
||||
|
||||
// go back to inline database view, expect field to be shown
|
||||
await tester.tapTabBarLinkedViewByViewName('Untitled');
|
||||
tester.findFieldWithName('New field 1');
|
||||
@@ -60,5 +66,40 @@ void main() {
|
||||
await tester.tapDatabaseSortButton();
|
||||
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
|
||||
});
|
||||
|
||||
testWidgets('field cell width', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
// create a database and add a linked database view
|
||||
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
|
||||
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
|
||||
|
||||
// create a field
|
||||
await tester.scrollToRight(find.byType(GridPage));
|
||||
await tester.tapNewPropertyButton();
|
||||
await tester.renameField('New field 1');
|
||||
await tester.dismissFieldEditor();
|
||||
|
||||
// check the width of the field
|
||||
expect(tester.getFieldWidth('New field 1'), 150);
|
||||
|
||||
// change the width of the field
|
||||
await tester.changeFieldWidth('New field 1', 200);
|
||||
expect(tester.getFieldWidth('New field 1'), 205);
|
||||
|
||||
// create another field, New field 1 to be same width
|
||||
await tester.tapNewPropertyButton();
|
||||
await tester.dismissFieldEditor();
|
||||
expect(tester.getFieldWidth('New field 1'), 205);
|
||||
|
||||
// go back to inline database view, expect New field 1 to be 150px
|
||||
await tester.tapTabBarLinkedViewByViewName('Untitled');
|
||||
expect(tester.getFieldWidth('New field 1'), 150);
|
||||
|
||||
// go back to linked database view, expect New field 1 to be 205px
|
||||
await tester.tapTabBarLinkedViewByViewName('Grid');
|
||||
expect(tester.getFieldWidth('New field 1'), 205);
|
||||
});
|
||||
});
|
||||
}
|
||||
+10
-7
@@ -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,
|
||||
|
||||
+29
-15
@@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
@@ -320,8 +322,14 @@ void main() {
|
||||
(tester) async {
|
||||
const url = 'https://appflowy.io';
|
||||
await tester.pasteContent(plainText: url, (editorState) async {
|
||||
final pasteAsMenu = find.byType(PasteAsMenu);
|
||||
expect(pasteAsMenu, findsOneWidget);
|
||||
final bookmarkButton = find.text(
|
||||
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
|
||||
);
|
||||
await tester.tapButton(bookmarkButton);
|
||||
// the second one is the paragraph node
|
||||
expect(editorState.document.root.children.length, 2);
|
||||
expect(editorState.document.root.children.length, 1);
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
expect(node.type, LinkPreviewBlockKeys.type);
|
||||
expect(node.attributes[LinkPreviewBlockKeys.url], url);
|
||||
@@ -333,19 +341,19 @@ void main() {
|
||||
await tester.hoverOnWidget(
|
||||
find.byType(CustomLinkPreviewWidget),
|
||||
onHover: () async {
|
||||
final convertToLinkButton = find.byWidgetPredicate((widget) {
|
||||
return widget is MenuBlockButton &&
|
||||
widget.tooltip ==
|
||||
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
|
||||
});
|
||||
/// show menu
|
||||
final menu = find.byType(CustomLinkPreviewMenu);
|
||||
expect(menu, findsOneWidget);
|
||||
await tester.tapButton(menu);
|
||||
|
||||
final convertToLinkButton = find.text(
|
||||
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl.tr(),
|
||||
);
|
||||
expect(convertToLinkButton, findsOneWidget);
|
||||
await tester.tap(convertToLinkButton);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(convertToLinkButton);
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
final textNode = editorState.getNodeAtPath([0])!;
|
||||
expect(textNode.type, ParagraphBlockKeys.type);
|
||||
@@ -363,14 +371,20 @@ void main() {
|
||||
(tester) async {
|
||||
const url = 'https://appflowy.io';
|
||||
await tester.pasteContent(plainText: url, (editorState) async {
|
||||
final pasteAsMenu = find.byType(PasteAsMenu);
|
||||
expect(pasteAsMenu, findsOneWidget);
|
||||
final bookmarkButton = find.text(
|
||||
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
|
||||
);
|
||||
await tester.tapButton(bookmarkButton);
|
||||
// the second one is the paragraph node
|
||||
expect(editorState.document.root.children.length, 2);
|
||||
expect(editorState.document.root.children.length, 1);
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
expect(node.type, LinkPreviewBlockKeys.type);
|
||||
expect(node.attributes[LinkPreviewBlockKeys.url], url);
|
||||
});
|
||||
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyZ,
|
||||
isControlPressed:
|
||||
@@ -521,7 +535,7 @@ void main() {
|
||||
|
||||
extension on WidgetTester {
|
||||
Future<void> pasteContent(
|
||||
void Function(EditorState editorState) test, {
|
||||
FutureOr<void> Function(EditorState editorState) test, {
|
||||
Future<void> Function(EditorState editorState)? beforeTest,
|
||||
String? plainText,
|
||||
String? html,
|
||||
@@ -558,6 +572,6 @@ extension on WidgetTester {
|
||||
);
|
||||
await pumpAndSettle(const Duration(milliseconds: 1000));
|
||||
|
||||
test(editor.getCurrentEditorState());
|
||||
await test(editor.getCurrentEditorState());
|
||||
}
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import '../../shared/util.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const avaliableLink = 'https://appflowy.io/',
|
||||
unavailableLink = 'www.thereIsNoting.com';
|
||||
|
||||
Future<void> preparePage(WidgetTester tester, {String? pageName}) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent(name: pageName);
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
}
|
||||
|
||||
Future<void> pasteLink(WidgetTester tester, String link) async {
|
||||
await getIt<ClipboardService>()
|
||||
.setData(ClipboardServiceData(plainText: link));
|
||||
|
||||
/// paste the link
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyV,
|
||||
isControlPressed: Platform.isLinux || Platform.isWindows,
|
||||
isMetaPressed: Platform.isMacOS,
|
||||
);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
Future<void> pasteAs(
|
||||
WidgetTester tester,
|
||||
String link,
|
||||
PasteMenuType type, {
|
||||
Duration waitTime = const Duration(milliseconds: 500),
|
||||
}) async {
|
||||
await pasteLink(tester, link);
|
||||
final convertToMentionButton = find.text(type.title);
|
||||
await tester.tapButton(convertToMentionButton);
|
||||
await tester.pumpAndSettle(waitTime);
|
||||
}
|
||||
|
||||
void checkUrl(Node node, String link) {
|
||||
expect(node.type, ParagraphBlockKeys.type);
|
||||
expect(node.delta!.toJson(), [
|
||||
{
|
||||
'insert': link,
|
||||
'attributes': {'href': link},
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
void checkMention(Node node, String link) {
|
||||
final delta = node.delta!;
|
||||
final insert = (delta.first as TextInsert).text;
|
||||
final attributes = delta.first.attributes;
|
||||
expect(insert, MentionBlockKeys.mentionChar);
|
||||
final mention =
|
||||
attributes?[MentionBlockKeys.mention] as Map<String, dynamic>;
|
||||
expect(mention[MentionBlockKeys.type], MentionType.externalLink.name);
|
||||
expect(mention[MentionBlockKeys.url], avaliableLink);
|
||||
}
|
||||
|
||||
void checkBookmark(Node node, String link) {
|
||||
expect(node.type, LinkPreviewBlockKeys.type);
|
||||
expect(node.attributes[LinkPreviewBlockKeys.url], link);
|
||||
}
|
||||
|
||||
void checkEmbed(Node node, String link) {
|
||||
expect(node.type, LinkPreviewBlockKeys.type);
|
||||
expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed);
|
||||
expect(node.attributes[LinkPreviewBlockKeys.url], link);
|
||||
}
|
||||
|
||||
group('Paste as URL', () {
|
||||
Future<void> pasteAndTurnInto(
|
||||
WidgetTester tester,
|
||||
String link,
|
||||
String title,
|
||||
) async {
|
||||
await pasteLink(tester, link);
|
||||
final convertToLinkButton = find
|
||||
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
|
||||
await tester.tapButton(convertToLinkButton);
|
||||
|
||||
/// hover link and turn into mention
|
||||
await tester.hoverOnWidget(
|
||||
find.byType(LinkHoverTrigger),
|
||||
onHover: () async {
|
||||
final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m);
|
||||
await tester.tapButton(turnintoButton);
|
||||
final convertToButton = find.text(title);
|
||||
await tester.tapButton(convertToButton);
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('paste a link', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteLink(tester, link);
|
||||
final convertToLinkButton = find
|
||||
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
|
||||
await tester.tapButton(convertToLinkButton);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkUrl(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link and turn into mention', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAndTurnInto(
|
||||
tester,
|
||||
link,
|
||||
LinkConvertMenuCommand.toMention.title,
|
||||
);
|
||||
|
||||
/// check metion values
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkMention(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link and turn into bookmark', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAndTurnInto(
|
||||
tester,
|
||||
link,
|
||||
LinkConvertMenuCommand.toBookmark.title,
|
||||
);
|
||||
|
||||
/// check metion values
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link and turn into embed', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAndTurnInto(
|
||||
tester,
|
||||
link,
|
||||
LinkConvertMenuCommand.toEmbed.title,
|
||||
);
|
||||
|
||||
/// check metion values
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkEmbed(node, link);
|
||||
});
|
||||
});
|
||||
|
||||
group('Paste as Mention', () {
|
||||
Future<void> pasteAsMention(WidgetTester tester, String link) =>
|
||||
pasteAs(tester, link, PasteMenuType.mention);
|
||||
|
||||
String getMentionLink(Node node) {
|
||||
final insert = node.delta?.first as TextInsert?;
|
||||
final mention = insert?.attributes?[MentionBlockKeys.mention]
|
||||
as Map<String, dynamic>?;
|
||||
return mention?[MentionBlockKeys.url] ?? '';
|
||||
}
|
||||
|
||||
Future<void> hoverMentionAndClick(
|
||||
WidgetTester tester,
|
||||
String command,
|
||||
) async {
|
||||
final mentionLink = find.byType(MentionLinkBlock);
|
||||
expect(mentionLink, findsOneWidget);
|
||||
await tester.hoverOnWidget(
|
||||
mentionLink,
|
||||
onHover: () async {
|
||||
final errorPreview = find.byType(MentionLinkErrorPreview);
|
||||
expect(errorPreview, findsOneWidget);
|
||||
final convertButton = find.byFlowySvg(FlowySvgs.turninto_m);
|
||||
await tester.tapButton(convertButton);
|
||||
final menuButton = find.text(command);
|
||||
await tester.tapButton(menuButton);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('paste a link as mention', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsMention(tester, link);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkMention(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste as mention and copy link', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsMention(tester, link);
|
||||
final mentionLink = find.byType(MentionLinkBlock);
|
||||
expect(mentionLink, findsOneWidget);
|
||||
await tester.hoverOnWidget(
|
||||
mentionLink,
|
||||
onHover: () async {
|
||||
final preview = find.byType(MentionLinkPreview);
|
||||
if (!preview.hasFound) {
|
||||
final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(copyButton);
|
||||
} else {
|
||||
final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
|
||||
await tester.tapButton(moreOptionButton);
|
||||
final copyButton =
|
||||
find.text(MentionLinktMenuCommand.copyLink.title);
|
||||
await tester.tapButton(copyButton);
|
||||
}
|
||||
},
|
||||
);
|
||||
final clipboardContent = await getIt<ClipboardService>().getData();
|
||||
expect(clipboardContent.plainText, link);
|
||||
});
|
||||
|
||||
testWidgets('paste as error mention and turninto url', (tester) async {
|
||||
String link = unavailableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsMention(tester, link);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
link = getMentionLink(node);
|
||||
await hoverMentionAndClick(
|
||||
tester,
|
||||
MentionLinktErrorMenuCommand.toURL.title,
|
||||
);
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
checkUrl(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste as error mention and turninto embed', (tester) async {
|
||||
String link = unavailableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsMention(tester, link);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
link = getMentionLink(node);
|
||||
await hoverMentionAndClick(
|
||||
tester,
|
||||
MentionLinktErrorMenuCommand.toEmbed.title,
|
||||
);
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
checkEmbed(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste as error mention and turninto bookmark', (tester) async {
|
||||
String link = unavailableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsMention(tester, link);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
link = getMentionLink(node);
|
||||
await hoverMentionAndClick(
|
||||
tester,
|
||||
MentionLinktErrorMenuCommand.toBookmark.title,
|
||||
);
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste as error mention and remove link', (tester) async {
|
||||
String link = unavailableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsMention(tester, link);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
link = getMentionLink(node);
|
||||
await hoverMentionAndClick(
|
||||
tester,
|
||||
MentionLinktErrorMenuCommand.removeLink.title,
|
||||
);
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(node.type, ParagraphBlockKeys.type);
|
||||
expect(node.delta!.toJson(), [
|
||||
{'insert': link},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('Paste as Bookmark', () {
|
||||
Future<void> pasteAsBookmark(WidgetTester tester, String link) =>
|
||||
pasteAs(tester, link, PasteMenuType.bookmark);
|
||||
|
||||
Future<void> hoverAndClick(
|
||||
WidgetTester tester,
|
||||
LinkPreviewMenuCommand command,
|
||||
) async {
|
||||
final bookmark = find.byType(CustomLinkPreviewBlockComponent);
|
||||
expect(bookmark, findsOneWidget);
|
||||
await tester.hoverOnWidget(
|
||||
bookmark,
|
||||
onHover: () async {
|
||||
final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
|
||||
await tester.tapButton(menuButton);
|
||||
final commandButton = find.text(command.title);
|
||||
await tester.tapButton(commandButton);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('paste a link as bookmark', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and convert to mention',
|
||||
(tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkMention(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and convert to url', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkUrl(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and convert to embed',
|
||||
(tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkEmbed(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and copy link', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink);
|
||||
final clipboardContent = await getIt<ClipboardService>().getData();
|
||||
expect(clipboardContent.plainText, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and replace link', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
await hoverAndClick(tester, LinkPreviewMenuCommand.replace);
|
||||
await tester.simulateKeyEvent(
|
||||
LogicalKeyboardKey.keyA,
|
||||
isControlPressed: Platform.isLinux || Platform.isWindows,
|
||||
isMetaPressed: Platform.isMacOS,
|
||||
);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
|
||||
await tester.enterText(find.byType(TextFormField), unavailableLink);
|
||||
await tester.tapButton(find.text(LocaleKeys.button_replace.tr()));
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, unavailableLink);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and remove link', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsBookmark(tester, link);
|
||||
await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
expect(node.type, ParagraphBlockKeys.type);
|
||||
expect(node.delta!.toJson(), [
|
||||
{'insert': link},
|
||||
]);
|
||||
});
|
||||
});
|
||||
group('Paste as Embed', () {
|
||||
Future<void> pasteAsEmbed(WidgetTester tester, String link) =>
|
||||
pasteAs(tester, link, PasteMenuType.embed);
|
||||
|
||||
Future<void> hoverAndConvert(
|
||||
WidgetTester tester,
|
||||
PasteMenuType command,
|
||||
) async {
|
||||
final embed = find.byType(LinkEmbedBlockComponent);
|
||||
expect(embed, findsOneWidget);
|
||||
await tester.hoverOnWidget(
|
||||
embed,
|
||||
onHover: () async {
|
||||
final menuButton = find.byFlowySvg(FlowySvgs.turninto_m);
|
||||
await tester.tapButton(menuButton);
|
||||
final commandButton = find.text(command.title);
|
||||
await tester.tapButton(commandButton);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('paste a link as embed', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkEmbed(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and convert to mention',
|
||||
(tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.mention);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkMention(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and convert to url', (tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.url);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkUrl(node, link);
|
||||
});
|
||||
|
||||
testWidgets('paste a link as bookmark and convert to bookmark',
|
||||
(tester) async {
|
||||
final link = avaliableLink;
|
||||
await preparePage(tester);
|
||||
await pasteAsEmbed(tester, link);
|
||||
await hoverAndConvert(tester, PasteMenuType.bookmark);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
});
|
||||
}
|
||||
+6
-8
@@ -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,
|
||||
};
|
||||
|
||||
+37
@@ -1,5 +1,6 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -47,5 +48,41 @@ void main() {
|
||||
|
||||
expect(editorState.selection!.start.offset, 0);
|
||||
});
|
||||
|
||||
testWidgets('select and delete text', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
/// create a new document
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
/// input text
|
||||
final editor = tester.editor;
|
||||
final editorState = editor.getCurrentEditorState();
|
||||
|
||||
const inputText = 'Test for text selection and deletion';
|
||||
final texts = inputText.split(' ');
|
||||
await editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(inputText);
|
||||
|
||||
/// selecte and delete
|
||||
int index = 0;
|
||||
while (texts.isNotEmpty) {
|
||||
final text = texts.removeAt(0);
|
||||
await tester.editor.updateSelection(
|
||||
Selection(
|
||||
start: Position(path: [0], offset: index),
|
||||
end: Position(path: [0], offset: index + text.length),
|
||||
),
|
||||
);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
|
||||
index++;
|
||||
}
|
||||
|
||||
/// excpete the text value is correct
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
final nodeText = node.delta?.toPlainText() ?? '';
|
||||
expect(nodeText, ' ' * (index - 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
@@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart'
|
||||
as document_with_multi_image_block_test;
|
||||
import 'document_with_simple_table_test.dart'
|
||||
as document_with_simple_table_test;
|
||||
import 'document_link_preview_test.dart' as document_link_preview_test;
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -28,4 +29,5 @@ void main() {
|
||||
document_find_menu_test.main();
|
||||
document_toolbar_test.main();
|
||||
document_with_simple_table_test.main();
|
||||
document_link_preview_test.main();
|
||||
}
|
||||
+335
-15
@@ -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,302 @@ 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,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('toolbar will not rebuild after click item', (tester) async {
|
||||
const text = 'Test rebuilding';
|
||||
await prepareForToolbar(tester, text);
|
||||
Finder toolbar = find.byType(DesktopFloatingToolbar);
|
||||
Element toolbarElement = toolbar.evaluate().first;
|
||||
final elementHashcode = toolbarElement.hashCode;
|
||||
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
|
||||
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
|
||||
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
|
||||
|
||||
/// tap format buttons
|
||||
await tester.tapButton(boldButton);
|
||||
await tester.tapButton(underlineButton);
|
||||
await tester.tapButton(italicButton);
|
||||
toolbar = find.byType(DesktopFloatingToolbar);
|
||||
toolbarElement = toolbar.evaluate().first;
|
||||
|
||||
/// check if the toolbar is not rebuilt
|
||||
expect(elementHashcode, toolbarElement.hashCode);
|
||||
final editorState = tester.editor.getCurrentEditorState();
|
||||
|
||||
/// check text formats
|
||||
expect(
|
||||
editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
editorState
|
||||
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('document toolbar: link', () {
|
||||
String? getLinkFromNode(Node node) {
|
||||
for (final insert in node.delta!) {
|
||||
final link = insert.attributes?.href;
|
||||
if (link != null) return link;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool isPageLink(Node node) {
|
||||
for (final insert in node.delta!) {
|
||||
final isPage = insert.attributes?.isPage;
|
||||
if (isPage == true) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String getNodeText(Node node) {
|
||||
for (final insert in node.delta!) {
|
||||
if (insert is TextInsert) return insert.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
testWidgets('insert link and remove link', (tester) async {
|
||||
const text = 'insert link', link = 'https://test.appflowy.cloud';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
final toolbar = find.byType(DesktopFloatingToolbar);
|
||||
expect(toolbar, findsOneWidget);
|
||||
|
||||
/// tap link button to show CreateLinkMenu
|
||||
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(linkButton);
|
||||
final createLinkMenu = find.byType(LinkCreateMenu);
|
||||
expect(createLinkMenu, findsOneWidget);
|
||||
|
||||
/// test esc to close
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
expect(toolbar, findsNothing);
|
||||
|
||||
/// show toolbar again
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await selectText(tester, text);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
/// insert link
|
||||
final textField = find.descendant(
|
||||
of: createLinkMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, link);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getLinkFromNode(node), link);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
|
||||
/// hover link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
final hoverMenu = find.byType(LinkHoverMenu);
|
||||
expect(hoverMenu, findsOneWidget);
|
||||
|
||||
/// copy link
|
||||
final copyButton = find.descendant(
|
||||
of: hoverMenu,
|
||||
matching: find.byFlowySvg(FlowySvgs.toolbar_link_m),
|
||||
);
|
||||
await tester.tapButton(copyButton);
|
||||
final clipboardContent = await getIt<ClipboardService>().getData();
|
||||
final plainText = clipboardContent.plainText;
|
||||
expect(plainText, link);
|
||||
|
||||
/// remove link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(getLinkFromNode(node), null);
|
||||
});
|
||||
|
||||
testWidgets('insert link and edit link', (tester) async {
|
||||
const text = 'edit link',
|
||||
link = 'https://test.appflowy.cloud',
|
||||
afterText = '$text after';
|
||||
await prepareForToolbar(tester, text);
|
||||
|
||||
/// tap link button to show CreateLinkMenu
|
||||
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
|
||||
await tester.tapButton(linkButton);
|
||||
|
||||
/// search for page and select it
|
||||
final textField = find.descendant(
|
||||
of: find.byType(LinkCreateMenu),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(textField, gettingStarted);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
|
||||
|
||||
Node node = tester.editor.getNodeAtPath([0]);
|
||||
expect(isPageLink(node), true);
|
||||
expect(getLinkFromNode(node) == link, false);
|
||||
|
||||
/// hover link
|
||||
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
|
||||
|
||||
/// click edit button to show LinkEditMenu
|
||||
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
|
||||
await tester.tapButton(editButton);
|
||||
final linkEditMenu = find.byType(LinkEditMenu);
|
||||
expect(linkEditMenu, findsOneWidget);
|
||||
|
||||
/// change the link text
|
||||
final titleField = find.descendant(
|
||||
of: linkEditMenu,
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(titleField, afterText);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapButton(
|
||||
find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)),
|
||||
);
|
||||
final linkField = find.ancestor(
|
||||
of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()),
|
||||
matching: find.byType(TextFormField),
|
||||
);
|
||||
await tester.enterText(linkField, link);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
|
||||
/// apply the change
|
||||
final applyButton =
|
||||
find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr());
|
||||
await tester.tapButton(applyButton);
|
||||
|
||||
node = tester.editor.getNodeAtPath([0]);
|
||||
expect(isPageLink(node), false);
|
||||
expect(getLinkFromNode(node), link);
|
||||
expect(getNodeText(node), afterText);
|
||||
});
|
||||
});
|
||||
}
|
||||
+77
-19
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+3
-8
@@ -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();
|
||||
|
||||
+108
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/emoji/emoji_handler.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
@@ -39,4 +41,110 @@ void main() {
|
||||
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('insert emoji by colon', () {
|
||||
Future<void> createNewDocumentAndShowEmojiList(
|
||||
WidgetTester tester, {
|
||||
String? search,
|
||||
}) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.ime.insertText(':${search ?? 'a'}');
|
||||
await tester.pumpAndSettle(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
testWidgets('insert with click', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsOneWidget);
|
||||
final emojiButtons =
|
||||
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
|
||||
final firstTextFinder = find.descendant(
|
||||
of: emojiButtons.first,
|
||||
matching: find.byType(FlowyText),
|
||||
);
|
||||
final emojiText =
|
||||
(firstTextFinder.evaluate().first.widget as FlowyText).text;
|
||||
|
||||
/// click first emoji item
|
||||
await tester.tapButton(emojiButtons.first);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
|
||||
/// except the emoji is in document
|
||||
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
|
||||
});
|
||||
|
||||
testWidgets('insert with arrow and enter', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester);
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsOneWidget);
|
||||
final emojiButtons =
|
||||
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
|
||||
|
||||
/// tap arrow down and arrow up
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
|
||||
final firstTextFinder = find.descendant(
|
||||
of: emojiButtons.first,
|
||||
matching: find.byType(FlowyText),
|
||||
);
|
||||
final emojiText =
|
||||
(firstTextFinder.evaluate().first.widget as FlowyText).text;
|
||||
|
||||
/// tap enter
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
|
||||
/// except the emoji is in document
|
||||
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
|
||||
});
|
||||
|
||||
testWidgets('insert with searching', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester, search: 's');
|
||||
|
||||
/// search for `smiling eyes`, IME is not working, use keyboard input
|
||||
final searchText = [
|
||||
LogicalKeyboardKey.keyM,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyL,
|
||||
LogicalKeyboardKey.keyI,
|
||||
LogicalKeyboardKey.keyN,
|
||||
LogicalKeyboardKey.keyG,
|
||||
LogicalKeyboardKey.space,
|
||||
LogicalKeyboardKey.keyE,
|
||||
LogicalKeyboardKey.keyY,
|
||||
LogicalKeyboardKey.keyE,
|
||||
LogicalKeyboardKey.keyS,
|
||||
];
|
||||
|
||||
for (final key in searchText) {
|
||||
await tester.simulateKeyEvent(key);
|
||||
}
|
||||
|
||||
/// tap enter
|
||||
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
|
||||
|
||||
/// except the emoji is in document
|
||||
expect(firstNode.delta!.toPlainText().contains('😄'), true);
|
||||
});
|
||||
|
||||
testWidgets('start searching with sapce', (tester) async {
|
||||
await createNewDocumentAndShowEmojiList(tester, search: ' ');
|
||||
|
||||
/// emoji list is showing
|
||||
final emojiHandler = find.byType(EmojiHandler);
|
||||
expect(emojiHandler, findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
-1
@@ -13,7 +13,6 @@ void main() {
|
||||
hotkeys_test.main();
|
||||
emoji_shortcut_test.main();
|
||||
hotkeys_test.main();
|
||||
emoji_shortcut_test.main();
|
||||
share_markdown_test.main();
|
||||
import_files_test.main();
|
||||
zoom_in_out_test.main();
|
||||
|
||||
@@ -37,5 +37,20 @@ void main() {
|
||||
await tester.tap(actionWidgets.last);
|
||||
expect(find.byType(MentionPageBlock), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('create subpage with at menu', (tester) async {
|
||||
await tester.launchInAnonymousMode();
|
||||
await tester.createNewDocumentOnMobile(title);
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
const subpageName = 'Subpage';
|
||||
await tester.ime.insertText('[[$subpageName');
|
||||
await tester.pumpAndSettle();
|
||||
final actionWidgets = find.byType(MobileInlineActionsWidget);
|
||||
await tester.tapButton(actionWidgets.first);
|
||||
final firstNode =
|
||||
tester.editor.getCurrentEditorState().getNodeAtPath([0]);
|
||||
assert(firstNode != null);
|
||||
expect(firstNode!.delta?.toPlainText().contains('['), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -54,8 +54,6 @@ extension AppFlowyTestBase on WidgetTester {
|
||||
final rustEnvs = <String, String>{};
|
||||
if (cloudType != null) {
|
||||
switch (cloudType) {
|
||||
case AuthenticatorType.local:
|
||||
break;
|
||||
case AuthenticatorType.appflowyCloudSelfHost:
|
||||
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
|
||||
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
|
||||
@@ -71,9 +69,6 @@ extension AppFlowyTestBase on WidgetTester {
|
||||
() async {
|
||||
if (cloudType != null) {
|
||||
switch (cloudType) {
|
||||
case AuthenticatorType.local:
|
||||
await useLocalServer();
|
||||
break;
|
||||
case AuthenticatorType.appflowyCloudSelfHost:
|
||||
await useTestSelfHostedAppFlowyCloud();
|
||||
getIt.unregister<AuthService>();
|
||||
|
||||
@@ -617,7 +617,7 @@ extension CommonOperations on WidgetTester {
|
||||
);
|
||||
final distanceY = getCenter(to).dy - getCenter(from).dx;
|
||||
await drag(from, Offset(0, distanceY));
|
||||
await pumpAndSettle();
|
||||
await pumpAndSettle(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
// tap the button with [FlowySvgData]
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/calculations/calculation_type_ext.dart';
|
||||
import 'package:appflowy/plugins/database/application/field/filter_entities.dart';
|
||||
import 'package:appflowy/plugins/database/board/presentation/board_page.dart';
|
||||
import 'package:appflowy/plugins/database/board/presentation/widgets/board_column_header.dart';
|
||||
import 'package:appflowy/plugins/database/calendar/application/calendar_bloc.dart';
|
||||
@@ -27,10 +19,11 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checkbox.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/checklist.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/choicechip.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/condition_list.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/option_list.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/select_option/select_option.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/text.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/choicechip/date.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/disclosure_button.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/footer/grid_footer.dart';
|
||||
@@ -44,6 +37,7 @@ import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/filt
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/toolbar/sort_button.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart';
|
||||
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart';
|
||||
@@ -76,6 +70,8 @@ import 'package:appflowy/plugins/database/widgets/setting/database_setting_actio
|
||||
import 'package:appflowy/plugins/database/widgets/setting/database_settings_list.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/setting/setting_button.dart';
|
||||
import 'package:appflowy/plugins/database/widgets/setting/setting_property_list.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/util/field_type_extension.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
|
||||
@@ -90,6 +86,9 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text_input.dart';
|
||||
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
// Non-exported member of the table_calendar library
|
||||
@@ -943,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> changeFieldWidth(String fieldName, double width) async {
|
||||
final field = find.byWidgetPredicate(
|
||||
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
|
||||
);
|
||||
await hoverOnWidget(
|
||||
field,
|
||||
onHover: () async {
|
||||
final dragHandle = find.descendant(
|
||||
of: field,
|
||||
matching: find.byType(DragToExpandLine),
|
||||
);
|
||||
await drag(dragHandle, Offset(width - getSize(field).width, 0));
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double getFieldWidth(String fieldName) {
|
||||
final field = find.byWidgetPredicate(
|
||||
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
|
||||
);
|
||||
|
||||
return getSize(field).width;
|
||||
}
|
||||
|
||||
Future<void> findDateEditor(dynamic matcher) async {
|
||||
final finder = find.byType(DateCellEditor);
|
||||
expect(finder, matcher);
|
||||
@@ -1572,7 +1596,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -307,9 +307,11 @@ class EditorOperations {
|
||||
Future<void> openTurnIntoMenu(Path path) async {
|
||||
await hoverAndClickOptionMenuButton(path);
|
||||
await tester.tapButton(
|
||||
find.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
|
||||
),
|
||||
find
|
||||
.findTextInFlowyText(
|
||||
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/ai/service/error.dart';
|
||||
import 'package:appflowy/ai/service/openai_client.dart';
|
||||
import 'package:appflowy/ai/service/text_completion.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MyMockClient extends Mock implements http.Client {
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
final requestType = request.method;
|
||||
final requestUri = request.url;
|
||||
|
||||
if (requestType == 'POST' &&
|
||||
requestUri == OpenAIRequestType.textCompletion.uri) {
|
||||
final responseHeaders = <String, String>{
|
||||
'content-type': 'text/event-stream',
|
||||
};
|
||||
final responseBody = Stream.fromIterable([
|
||||
utf8.encode(
|
||||
'{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}',
|
||||
),
|
||||
utf8.encode('\n'),
|
||||
utf8.encode('[DONE]'),
|
||||
]);
|
||||
|
||||
// Return a mocked response with the expected data
|
||||
return http.StreamedResponse(responseBody, 200, headers: responseHeaders);
|
||||
}
|
||||
|
||||
// Return an error response for any other request
|
||||
return http.StreamedResponse(const Stream.empty(), 404);
|
||||
}
|
||||
}
|
||||
|
||||
class MockOpenAIRepository extends HttpOpenAIRepository {
|
||||
MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient());
|
||||
|
||||
@override
|
||||
Future<void> getStreamedCompletions({
|
||||
required String prompt,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(TextCompletionResponse response) onProcess,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
String? suffix,
|
||||
int maxTokens = 2048,
|
||||
double temperature = 0.3,
|
||||
bool useAction = false,
|
||||
}) async {
|
||||
final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
|
||||
final response = await client.send(request);
|
||||
|
||||
String previousSyntax = '';
|
||||
if (response.statusCode == 200) {
|
||||
await for (final chunk in response.stream
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())) {
|
||||
await onStart();
|
||||
final data = chunk.trim().split('data: ');
|
||||
if (data[0] != '[DONE]') {
|
||||
final response = TextCompletionResponse.fromJson(
|
||||
json.decode(data[0]),
|
||||
);
|
||||
if (response.choices.isNotEmpty) {
|
||||
final text = response.choices.first.text;
|
||||
if (text == previousSyntax && text == '\n') {
|
||||
continue;
|
||||
}
|
||||
await onProcess(response);
|
||||
previousSyntax = response.choices.first.text;
|
||||
}
|
||||
} else {
|
||||
await onEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
|
||||
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
|
||||
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
|
||||
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
|
||||
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
|
||||
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
|
||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
|
||||
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
|
||||
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
|
||||
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
|
||||
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export 'service/ai_entities.dart';
|
||||
export 'service/ai_prompt_input_bloc.dart';
|
||||
export 'service/appflowy_ai_service.dart';
|
||||
export 'service/error.dart';
|
||||
export 'service/ai_model_state_notifier.dart';
|
||||
export 'service/select_model_bloc.dart';
|
||||
export 'widgets/loading_indicator.dart';
|
||||
export 'widgets/prompt_input/action_buttons.dart';
|
||||
export 'widgets/prompt_input/desktop_input_text_field.dart';
|
||||
export 'widgets/prompt_input/desktop_prompt_text_field.dart';
|
||||
export 'widgets/prompt_input/file_attachment_list.dart';
|
||||
export 'widgets/prompt_input/layout_define.dart';
|
||||
export 'widgets/prompt_input/mention_page_bottom_sheet.dart';
|
||||
@@ -9,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart';
|
||||
export 'widgets/prompt_input/predefined_format_buttons.dart';
|
||||
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
|
||||
export 'widgets/prompt_input/select_sources_menu.dart';
|
||||
export 'widgets/prompt_input/select_model_menu.dart';
|
||||
export 'widgets/prompt_input/send_button.dart';
|
||||
@@ -1,34 +0,0 @@
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
import 'error.dart';
|
||||
import 'text_completion.dart';
|
||||
|
||||
abstract class AIRepository {
|
||||
Future<void> getStreamedCompletions({
|
||||
required String prompt,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(TextCompletionResponse response) onProcess,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
String? suffix,
|
||||
int maxTokens = 2048,
|
||||
double temperature = 0.3,
|
||||
bool useAction = false,
|
||||
});
|
||||
|
||||
Future<void> streamCompletion({
|
||||
String? objectId,
|
||||
required String text,
|
||||
required CompletionTypePB completionType,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(String text) onProcess,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
});
|
||||
|
||||
Future<FlowyResult<List<String>, AIError>> generateImage({
|
||||
required String prompt,
|
||||
int n = 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class AIStreamEventPrefix {
|
||||
static const data = 'data:';
|
||||
static const error = 'error:';
|
||||
static const metadata = 'metadata:';
|
||||
static const start = 'start:';
|
||||
static const finish = 'finish:';
|
||||
static const comment = 'comment:';
|
||||
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
|
||||
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
|
||||
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
|
||||
static const localAINotReady = 'LOCAL_AI_NOT_READY';
|
||||
static const localAIDisabled = 'LOCAL_AI_DISABLED';
|
||||
}
|
||||
|
||||
enum AiType {
|
||||
cloud,
|
||||
local;
|
||||
|
||||
bool get isCloud => this == cloud;
|
||||
bool get isLocal => this == local;
|
||||
}
|
||||
|
||||
class PredefinedFormat extends Equatable {
|
||||
const PredefinedFormat({
|
||||
required this.imageFormat,
|
||||
required this.textFormat,
|
||||
});
|
||||
|
||||
final ImageFormat imageFormat;
|
||||
final TextFormat? textFormat;
|
||||
|
||||
PredefinedFormatPB toPB() {
|
||||
return PredefinedFormatPB(
|
||||
imageFormat: switch (imageFormat) {
|
||||
ImageFormat.text => ResponseImageFormatPB.TextOnly,
|
||||
ImageFormat.image => ResponseImageFormatPB.ImageOnly,
|
||||
ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
|
||||
},
|
||||
textFormat: switch (textFormat) {
|
||||
TextFormat.paragraph => ResponseTextFormatPB.Paragraph,
|
||||
TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
|
||||
TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
|
||||
TextFormat.table => ResponseTextFormatPB.Table,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [imageFormat, textFormat];
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
text,
|
||||
image,
|
||||
textAndImage;
|
||||
|
||||
bool get hasText => this == text || this == textAndImage;
|
||||
|
||||
FlowySvgData get icon {
|
||||
return switch (this) {
|
||||
ImageFormat.text => FlowySvgs.ai_text_s,
|
||||
ImageFormat.image => FlowySvgs.ai_image_s,
|
||||
ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
|
||||
};
|
||||
}
|
||||
|
||||
String get i18n {
|
||||
return switch (this) {
|
||||
ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
|
||||
ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
|
||||
ImageFormat.textAndImage =>
|
||||
LocaleKeys.chat_changeFormat_textAndImage.tr(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum TextFormat {
|
||||
paragraph,
|
||||
bulletList,
|
||||
numberedList,
|
||||
table;
|
||||
|
||||
FlowySvgData get icon {
|
||||
return switch (this) {
|
||||
TextFormat.paragraph => FlowySvgs.ai_paragraph_s,
|
||||
TextFormat.bulletList => FlowySvgs.ai_list_s,
|
||||
TextFormat.numberedList => FlowySvgs.ai_number_list_s,
|
||||
TextFormat.table => FlowySvgs.ai_table_s,
|
||||
};
|
||||
}
|
||||
|
||||
String get i18n {
|
||||
return switch (this) {
|
||||
TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(),
|
||||
TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
|
||||
TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
|
||||
TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:protobuf/protobuf.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
typedef OnModelStateChangedCallback = void Function(AiType, bool, String);
|
||||
typedef OnAvailableModelsChangedCallback = void Function(
|
||||
List<AIModelPB>,
|
||||
AIModelPB?,
|
||||
);
|
||||
|
||||
class AIModelStateNotifier {
|
||||
AIModelStateNotifier({required this.objectId})
|
||||
: _localAIListener =
|
||||
UniversalPlatform.isDesktop ? LocalAIStateListener() : null,
|
||||
_aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) {
|
||||
_startListening();
|
||||
_init();
|
||||
}
|
||||
|
||||
final String objectId;
|
||||
final LocalAIStateListener? _localAIListener;
|
||||
final AIModelSwitchListener _aiModelSwitchListener;
|
||||
LocalAIPB? _localAIState;
|
||||
AvailableModelsPB? _availableModels;
|
||||
|
||||
// callbacks
|
||||
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
|
||||
final List<OnAvailableModelsChangedCallback>
|
||||
_availableModelsChangedCallbacks = [];
|
||||
|
||||
void _startListening() {
|
||||
if (UniversalPlatform.isDesktop) {
|
||||
_localAIListener?.start(
|
||||
stateCallback: (state) async {
|
||||
_localAIState = state;
|
||||
_notifyStateChanged();
|
||||
|
||||
if (state.state == RunningStatePB.Running ||
|
||||
state.state == RunningStatePB.Stopped) {
|
||||
await _loadAvailableModels();
|
||||
_notifyAvailableModelsChanged();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_aiModelSwitchListener.start(
|
||||
onUpdateSelectedModel: (model) async {
|
||||
final updatedModels = _availableModels?.deepCopy()
|
||||
?..selectedModel = model;
|
||||
_availableModels = updatedModels;
|
||||
_notifyAvailableModelsChanged();
|
||||
|
||||
if (model.isLocal && UniversalPlatform.isDesktop) {
|
||||
await _loadLocalAiState();
|
||||
}
|
||||
_notifyStateChanged();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _init() async {
|
||||
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
|
||||
_notifyStateChanged();
|
||||
_notifyAvailableModelsChanged();
|
||||
}
|
||||
|
||||
void addListener({
|
||||
OnModelStateChangedCallback? onStateChanged,
|
||||
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
|
||||
}) {
|
||||
if (onStateChanged != null) {
|
||||
_stateChangedCallbacks.add(onStateChanged);
|
||||
}
|
||||
if (onAvailableModelsChanged != null) {
|
||||
_availableModelsChangedCallbacks.add(onAvailableModelsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
void removeListener({
|
||||
OnModelStateChangedCallback? onStateChanged,
|
||||
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
|
||||
}) {
|
||||
if (onStateChanged != null) {
|
||||
_stateChangedCallbacks.remove(onStateChanged);
|
||||
}
|
||||
if (onAvailableModelsChanged != null) {
|
||||
_availableModelsChangedCallbacks.remove(onAvailableModelsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_stateChangedCallbacks.clear();
|
||||
_availableModelsChangedCallbacks.clear();
|
||||
await _localAIListener?.stop();
|
||||
await _aiModelSwitchListener.stop();
|
||||
}
|
||||
|
||||
(AiType, String, bool) getState() {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
|
||||
final availableModels = _availableModels;
|
||||
final localAiState = _localAIState;
|
||||
|
||||
if (availableModels == null) {
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
if (localAiState == null) {
|
||||
Log.warn("Cannot get local AI state");
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
|
||||
if (!availableModels.selectedModel.isLocal) {
|
||||
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
|
||||
}
|
||||
|
||||
final editable = localAiState.state == RunningStatePB.Running;
|
||||
final hintText = editable
|
||||
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
|
||||
|
||||
return (AiType.local, hintText, editable);
|
||||
}
|
||||
|
||||
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
|
||||
final availableModels = _availableModels;
|
||||
if (availableModels == null) {
|
||||
return ([], null);
|
||||
}
|
||||
return (availableModels.models, availableModels.selectedModel);
|
||||
}
|
||||
|
||||
void _notifyAvailableModelsChanged() {
|
||||
final (models, selectedModel) = getAvailableModels();
|
||||
for (final callback in _availableModelsChangedCallbacks) {
|
||||
callback(models, selectedModel);
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyStateChanged() {
|
||||
final (type, hintText, isEditable) = getState();
|
||||
for (final callback in _stateChangedCallbacks) {
|
||||
callback(type, isEditable, hintText);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadAvailableModels() {
|
||||
final payload = AvailableModelsQueryPB(source: objectId);
|
||||
return AIEventGetAvailableModels(payload).send().fold(
|
||||
(models) => _availableModels = models,
|
||||
(err) => Log.error("Failed to get available models: $err"),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadLocalAiState() {
|
||||
return AIEventGetLocalAIState().send().fold(
|
||||
(localAIState) => _localAIState = localAIState,
|
||||
(error) => Log.error("Failed to get local AI state: $error"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension AiModelExtension on AIModelPB {
|
||||
bool get isDefault {
|
||||
return name == "Auto";
|
||||
}
|
||||
|
||||
String get i18n {
|
||||
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
|
||||
}
|
||||
}
|
||||
+58
-67
@@ -1,31 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import 'ai_entities.dart';
|
||||
|
||||
part 'ai_prompt_input_bloc.freezed.dart';
|
||||
|
||||
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
AIPromptInputBloc()
|
||||
: _listener = LocalLLMListener(),
|
||||
super(AIPromptInputState.initial()) {
|
||||
AIPromptInputBloc({
|
||||
required String objectId,
|
||||
required PredefinedFormat? predefinedFormat,
|
||||
}) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
|
||||
super(AIPromptInputState.initial(predefinedFormat)) {
|
||||
_dispatch();
|
||||
_startListening();
|
||||
_init();
|
||||
}
|
||||
|
||||
final LocalLLMListener _listener;
|
||||
final AIModelStateNotifier aiModelStateNotifier;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _listener.stop();
|
||||
await aiModelStateNotifier.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -33,38 +33,37 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
on<AIPromptInputEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
updateChatState: (LocalAIChatPB chatState) {
|
||||
// Only user enable chat with file and the plugin is already running
|
||||
final supportChatWithFile = chatState.fileEnabled &&
|
||||
chatState.pluginState.state == RunningStatePB.Running;
|
||||
|
||||
final aiType = chatState.pluginState.state == RunningStatePB.Running
|
||||
? AIType.localAI
|
||||
: AIType.appflowyAI;
|
||||
updateAIState: (aiType, editable, hintText) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
aiType: aiType,
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
chatState: chatState,
|
||||
editable: editable,
|
||||
hintText: hintText,
|
||||
),
|
||||
);
|
||||
},
|
||||
updatePluginState: (LocalAIPluginStatePB chatState) {
|
||||
final fileEnabled = state.chatState?.fileEnabled ?? false;
|
||||
final supportChatWithFile =
|
||||
fileEnabled && chatState.state == RunningStatePB.Running;
|
||||
|
||||
final aiType = chatState.state == RunningStatePB.Running
|
||||
? AIType.localAI
|
||||
: AIType.appflowyAI;
|
||||
|
||||
toggleShowPredefinedFormat: () {
|
||||
final showPredefinedFormats = !state.showPredefinedFormats;
|
||||
final predefinedFormat =
|
||||
showPredefinedFormats && state.predefinedFormat == null
|
||||
? PredefinedFormat(
|
||||
imageFormat: ImageFormat.text,
|
||||
textFormat: TextFormat.paragraph,
|
||||
)
|
||||
: null;
|
||||
emit(
|
||||
state.copyWith(
|
||||
supportChatWithFile: supportChatWithFile,
|
||||
aiType: aiType,
|
||||
showPredefinedFormats: showPredefinedFormats,
|
||||
predefinedFormat: predefinedFormat,
|
||||
),
|
||||
);
|
||||
},
|
||||
updatePredefinedFormat: (format) {
|
||||
if (!state.showPredefinedFormats) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(predefinedFormat: format));
|
||||
},
|
||||
attachFile: (filePath, fileName) {
|
||||
final newFile = ChatFile.fromFilePath(filePath);
|
||||
if (newFile != null) {
|
||||
@@ -105,29 +104,16 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
_listener.start(
|
||||
stateCallback: (pluginState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updatePluginState(pluginState));
|
||||
}
|
||||
},
|
||||
chatStateCallback: (chatState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updateChatState(chatState));
|
||||
}
|
||||
aiModelStateNotifier.addListener(
|
||||
onStateChanged: (aiType, editable, hintText) {
|
||||
add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _init() {
|
||||
AIEventGetLocalAIChatState().send().fold(
|
||||
(chatState) {
|
||||
if (!isClosed) {
|
||||
add(AIPromptInputEvent.updateChatState(chatState));
|
||||
}
|
||||
},
|
||||
Log.error,
|
||||
);
|
||||
final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
|
||||
add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
|
||||
}
|
||||
|
||||
Map<String, dynamic> consumeMetadata() {
|
||||
@@ -146,12 +132,17 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
|
||||
|
||||
@freezed
|
||||
class AIPromptInputEvent with _$AIPromptInputEvent {
|
||||
const factory AIPromptInputEvent.updateChatState(
|
||||
LocalAIChatPB chatState,
|
||||
) = _UpdateChatState;
|
||||
const factory AIPromptInputEvent.updatePluginState(
|
||||
LocalAIPluginStatePB chatState,
|
||||
) = _UpdatePluginState;
|
||||
const factory AIPromptInputEvent.updateAIState(
|
||||
AiType aiType,
|
||||
bool editable,
|
||||
String hintText,
|
||||
) = _UpdateAIState;
|
||||
|
||||
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
|
||||
_ToggleShowPredefinedFormat;
|
||||
const factory AIPromptInputEvent.updatePredefinedFormat(
|
||||
PredefinedFormat format,
|
||||
) = _UpdatePredefinedFormat;
|
||||
const factory AIPromptInputEvent.attachFile(
|
||||
String filePath,
|
||||
String fileName,
|
||||
@@ -165,25 +156,25 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
|
||||
@freezed
|
||||
class AIPromptInputState with _$AIPromptInputState {
|
||||
const factory AIPromptInputState({
|
||||
required AIType aiType,
|
||||
required AiType aiType,
|
||||
required bool supportChatWithFile,
|
||||
required LocalAIChatPB? chatState,
|
||||
required bool showPredefinedFormats,
|
||||
required PredefinedFormat? predefinedFormat,
|
||||
required List<ChatFile> attachedFiles,
|
||||
required List<ViewPB> mentionedPages,
|
||||
required bool editable,
|
||||
required String hintText,
|
||||
}) = _AIPromptInputState;
|
||||
|
||||
factory AIPromptInputState.initial() => const AIPromptInputState(
|
||||
aiType: AIType.appflowyAI,
|
||||
factory AIPromptInputState.initial(PredefinedFormat? format) =>
|
||||
AIPromptInputState(
|
||||
aiType: AiType.cloud,
|
||||
supportChatWithFile: false,
|
||||
chatState: null,
|
||||
showPredefinedFormats: format != null,
|
||||
predefinedFormat: format,
|
||||
attachedFiles: [],
|
||||
mentionedPages: [],
|
||||
editable: true,
|
||||
hintText: '',
|
||||
);
|
||||
}
|
||||
|
||||
enum AIType {
|
||||
appflowyAI,
|
||||
localAI;
|
||||
|
||||
bool get isLocalAI => this == localAI;
|
||||
}
|
||||
@@ -3,193 +3,202 @@ import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
|
||||
import 'package:appflowy/shared/list_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart' as fixnum;
|
||||
|
||||
import 'ai_client.dart';
|
||||
import 'ai_entities.dart';
|
||||
import 'error.dart';
|
||||
import 'text_completion.dart';
|
||||
|
||||
enum AskAIAction {
|
||||
summarize,
|
||||
fixSpelling,
|
||||
improveWriting,
|
||||
makeItLonger;
|
||||
enum LocalAIStreamingState {
|
||||
notReady,
|
||||
disabled,
|
||||
}
|
||||
|
||||
String get toInstruction => switch (this) {
|
||||
summarize => 'Tl;dr',
|
||||
fixSpelling => 'Correct this to standard English:',
|
||||
improveWriting => 'Rewrite this in your own words:',
|
||||
makeItLonger => 'Make this text longer:',
|
||||
};
|
||||
|
||||
String prompt(String input) => switch (this) {
|
||||
summarize => '$input\n\n$toInstruction',
|
||||
_ => "$toInstruction\n\n$input",
|
||||
};
|
||||
|
||||
static AskAIAction from(int index) => switch (index) {
|
||||
0 => summarize,
|
||||
1 => fixSpelling,
|
||||
2 => improveWriting,
|
||||
3 => makeItLonger,
|
||||
_ => fixSpelling
|
||||
};
|
||||
|
||||
String get name => switch (this) {
|
||||
summarize => LocaleKeys.document_plugins_smartEditSummarize.tr(),
|
||||
fixSpelling => LocaleKeys.document_plugins_smartEditFixSpelling.tr(),
|
||||
improveWriting =>
|
||||
LocaleKeys.document_plugins_smartEditImproveWriting.tr(),
|
||||
makeItLonger => LocaleKeys.document_plugins_smartEditMakeLonger.tr(),
|
||||
};
|
||||
abstract class AIRepository {
|
||||
Future<void> streamCompletion({
|
||||
String? objectId,
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(String text) processMessage,
|
||||
required Future<void> Function(String text) processAssistMessage,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
required void Function(LocalAIStreamingState state)
|
||||
onLocalAIStreamingStateChange,
|
||||
});
|
||||
}
|
||||
|
||||
class AppFlowyAIService implements AIRepository {
|
||||
@override
|
||||
Future<FlowyResult<List<String>, AIError>> generateImage({
|
||||
required String prompt,
|
||||
int n = 1,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStreamedCompletions({
|
||||
required String prompt,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(TextCompletionResponse response) onProcess,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
String? suffix,
|
||||
int maxTokens = 2048,
|
||||
double temperature = 0.3,
|
||||
bool useAction = false,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CompletionStream> streamCompletion({
|
||||
Future<(String, CompletionStream)?> streamCompletion({
|
||||
String? objectId,
|
||||
required String text,
|
||||
PredefinedFormat? format,
|
||||
List<String> sourceIds = const [],
|
||||
List<AiWriterRecord> history = const [],
|
||||
required CompletionTypePB completionType,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(String text) onProcess,
|
||||
required Future<void> Function(String text) processMessage,
|
||||
required Future<void> Function(String text) processAssistMessage,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
required void Function(LocalAIStreamingState state)
|
||||
onLocalAIStreamingStateChange,
|
||||
}) async {
|
||||
final stream = CompletionStream(
|
||||
onStart,
|
||||
onProcess,
|
||||
onEnd,
|
||||
onError,
|
||||
final stream = AppFlowyCompletionStream(
|
||||
onStart: onStart,
|
||||
processMessage: processMessage,
|
||||
processAssistMessage: processAssistMessage,
|
||||
processError: onError,
|
||||
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
|
||||
onEnd: onEnd,
|
||||
);
|
||||
final List<String> ragIds = [];
|
||||
if (objectId != null) {
|
||||
ragIds.add(objectId);
|
||||
}
|
||||
|
||||
final records = history.map((record) => record.toPB()).toList();
|
||||
|
||||
final payload = CompleteTextPB(
|
||||
text: text,
|
||||
completionType: completionType,
|
||||
format: format?.toPB(),
|
||||
streamPort: fixnum.Int64(stream.nativePort),
|
||||
objectId: objectId ?? "",
|
||||
ragIds: ragIds,
|
||||
objectId: objectId ?? '',
|
||||
ragIds: [
|
||||
if (objectId != null) objectId,
|
||||
...sourceIds,
|
||||
].unique(),
|
||||
history: records,
|
||||
);
|
||||
|
||||
// ignore: unawaited_futures
|
||||
AIEventCompleteText(payload).send();
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
CompletionTypePB completionTypeFromInt(AskAIAction action) {
|
||||
switch (action) {
|
||||
case AskAIAction.summarize:
|
||||
return CompletionTypePB.MakeShorter;
|
||||
case AskAIAction.fixSpelling:
|
||||
return CompletionTypePB.SpellingAndGrammar;
|
||||
case AskAIAction.improveWriting:
|
||||
return CompletionTypePB.ImproveWriting;
|
||||
case AskAIAction.makeItLonger:
|
||||
return CompletionTypePB.MakeLonger;
|
||||
}
|
||||
}
|
||||
|
||||
class CompletionStream {
|
||||
CompletionStream(
|
||||
Future<void> Function() onStart,
|
||||
Future<void> Function(String text) onProcess,
|
||||
Future<void> Function() onEnd,
|
||||
void Function(AIError error) onError,
|
||||
) {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(event) async {
|
||||
if (event == "AI_RESPONSE_LIMIT") {
|
||||
onError(
|
||||
AIError(
|
||||
message: LocaleKeys.sideBar_aiResponseLimit.tr(),
|
||||
code: AIErrorCode.aiResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
|
||||
onError(
|
||||
AIError(
|
||||
message: LocaleKeys.sideBar_aiImageResponseLimit.tr(),
|
||||
code: AIErrorCode.aiImageResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startsWith("AI_MAX_REQUIRED:")) {
|
||||
final msg = event.substring(16);
|
||||
onError(
|
||||
AIError(
|
||||
message: msg,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (event.startsWith("start:")) {
|
||||
await onStart();
|
||||
}
|
||||
|
||||
if (event.startsWith("data:")) {
|
||||
await onProcess(event.substring(5));
|
||||
}
|
||||
|
||||
if (event.startsWith("finish:")) {
|
||||
await onEnd();
|
||||
}
|
||||
|
||||
if (event.startsWith("error:")) {
|
||||
onError(AIError(message: event.substring(6)));
|
||||
}
|
||||
return AIEventCompleteText(payload).send().fold(
|
||||
(task) => (task.taskId, stream),
|
||||
(error) {
|
||||
Log.error(error);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CompletionStream {
|
||||
CompletionStream({
|
||||
required this.onStart,
|
||||
required this.processMessage,
|
||||
required this.processAssistMessage,
|
||||
required this.processError,
|
||||
required this.onLocalAIStreamingStateChange,
|
||||
required this.onEnd,
|
||||
});
|
||||
|
||||
final Future<void> Function() onStart;
|
||||
final Future<void> Function(String text) processMessage;
|
||||
final Future<void> Function(String text) processAssistMessage;
|
||||
final void Function(AIError error) processError;
|
||||
final void Function(LocalAIStreamingState state)
|
||||
onLocalAIStreamingStateChange;
|
||||
final Future<void> Function() onEnd;
|
||||
}
|
||||
|
||||
class AppFlowyCompletionStream extends CompletionStream {
|
||||
AppFlowyCompletionStream({
|
||||
required super.onStart,
|
||||
required super.processMessage,
|
||||
required super.processAssistMessage,
|
||||
required super.processError,
|
||||
required super.onEnd,
|
||||
required super.onLocalAIStreamingStateChange,
|
||||
}) {
|
||||
_startListening();
|
||||
}
|
||||
|
||||
final RawReceivePort _port = RawReceivePort();
|
||||
final StreamController<String> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<String> _subscription;
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
|
||||
void _startListening() {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(event) async {
|
||||
await _handleEvent(event);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _controller.close();
|
||||
await _subscription.cancel();
|
||||
_port.close();
|
||||
}
|
||||
|
||||
StreamSubscription<String> listen(
|
||||
void Function(String event)? onData,
|
||||
) {
|
||||
return _controller.stream.listen(onData);
|
||||
Future<void> _handleEvent(String event) async {
|
||||
// Check simple matches first
|
||||
if (event == AIStreamEventPrefix.aiResponseLimit) {
|
||||
processError(
|
||||
AIError(
|
||||
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
|
||||
code: AIErrorCode.aiResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event == AIStreamEventPrefix.aiImageResponseLimit) {
|
||||
processError(
|
||||
AIError(
|
||||
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
|
||||
code: AIErrorCode.aiImageResponseLimitExceeded,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, parse out prefix:content
|
||||
if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
|
||||
processError(
|
||||
AIError(
|
||||
message: event.substring(AIStreamEventPrefix.aiMaxRequired.length),
|
||||
code: AIErrorCode.other,
|
||||
),
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.start)) {
|
||||
await onStart();
|
||||
} else if (event.startsWith(AIStreamEventPrefix.data)) {
|
||||
await processMessage(
|
||||
event.substring(AIStreamEventPrefix.data.length),
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.comment)) {
|
||||
await processAssistMessage(
|
||||
event.substring(AIStreamEventPrefix.comment.length),
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.finish)) {
|
||||
await onEnd();
|
||||
} else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) {
|
||||
onLocalAIStreamingStateChange(
|
||||
LocalAIStreamingState.disabled,
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
|
||||
onLocalAIStreamingStateChange(
|
||||
LocalAIStreamingState.notReady,
|
||||
);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.error)) {
|
||||
processError(
|
||||
AIError(
|
||||
message: event.substring(AIStreamEventPrefix.error.length),
|
||||
code: AIErrorCode.other,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Log.debug('Unknown AI event: $event');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ part 'error.g.dart';
|
||||
class AIError with _$AIError {
|
||||
const factory AIError({
|
||||
required String message,
|
||||
@Default(AIErrorCode.other) AIErrorCode code,
|
||||
required AIErrorCode code,
|
||||
}) = _AIError;
|
||||
|
||||
factory AIError.fromJson(Map<String, Object?> json) =>
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'ai_client.dart';
|
||||
import 'error.dart';
|
||||
import 'text_completion.dart';
|
||||
|
||||
enum OpenAIRequestType {
|
||||
textCompletion,
|
||||
textEdit,
|
||||
imageGenerations;
|
||||
|
||||
Uri get uri {
|
||||
switch (this) {
|
||||
case OpenAIRequestType.textCompletion:
|
||||
return Uri.parse('https://api.openai.com/v1/completions');
|
||||
case OpenAIRequestType.textEdit:
|
||||
return Uri.parse('https://api.openai.com/v1/chat/completions');
|
||||
case OpenAIRequestType.imageGenerations:
|
||||
return Uri.parse('https://api.openai.com/v1/images/generations');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HttpOpenAIRepository implements AIRepository {
|
||||
const HttpOpenAIRepository({
|
||||
required this.client,
|
||||
required this.apiKey,
|
||||
});
|
||||
|
||||
final http.Client client;
|
||||
final String apiKey;
|
||||
|
||||
Map<String, String> get headers => {
|
||||
'Authorization': 'Bearer $apiKey',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> getStreamedCompletions({
|
||||
required String prompt,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(TextCompletionResponse response) onProcess,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
String? suffix,
|
||||
int maxTokens = 2048,
|
||||
double temperature = 0.3,
|
||||
bool useAction = false,
|
||||
}) async {
|
||||
final parameters = {
|
||||
'model': 'gpt-3.5-turbo-instruct',
|
||||
'prompt': prompt,
|
||||
'suffix': suffix,
|
||||
'max_tokens': maxTokens,
|
||||
'temperature': temperature,
|
||||
'stream': true,
|
||||
};
|
||||
|
||||
final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
|
||||
request.headers.addAll(headers);
|
||||
request.body = jsonEncode(parameters);
|
||||
|
||||
final response = await client.send(request);
|
||||
|
||||
// NEED TO REFACTOR.
|
||||
// WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE?
|
||||
// AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE?
|
||||
int syntax = 0;
|
||||
var previousSyntax = '';
|
||||
if (response.statusCode == 200) {
|
||||
await for (final chunk in response.stream
|
||||
.transform(const Utf8Decoder())
|
||||
.transform(const LineSplitter())) {
|
||||
syntax += 1;
|
||||
if (!useAction) {
|
||||
if (syntax == 3) {
|
||||
await onStart();
|
||||
continue;
|
||||
} else if (syntax < 3) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (syntax == 2) {
|
||||
await onStart();
|
||||
continue;
|
||||
} else if (syntax < 2) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
final data = chunk.trim().split('data: ');
|
||||
if (data.length > 1) {
|
||||
if (data[1] != '[DONE]') {
|
||||
final response = TextCompletionResponse.fromJson(
|
||||
json.decode(data[1]),
|
||||
);
|
||||
if (response.choices.isNotEmpty) {
|
||||
final text = response.choices.first.text;
|
||||
if (text == previousSyntax && text == '\n') {
|
||||
continue;
|
||||
}
|
||||
await onProcess(response);
|
||||
previousSyntax = response.choices.first.text;
|
||||
}
|
||||
} else {
|
||||
await onEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final body = await response.stream.bytesToString();
|
||||
onError(
|
||||
AIError.fromJson(json.decode(body)['error']),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FlowyResult<List<String>, AIError>> generateImage({
|
||||
required String prompt,
|
||||
int n = 1,
|
||||
}) async {
|
||||
final parameters = {
|
||||
'prompt': prompt,
|
||||
'n': n,
|
||||
'size': '512x512',
|
||||
};
|
||||
|
||||
try {
|
||||
final response = await client.post(
|
||||
OpenAIRequestType.imageGenerations.uri,
|
||||
headers: headers,
|
||||
body: json.encode(parameters),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(
|
||||
utf8.decode(response.bodyBytes),
|
||||
)['data'] as List;
|
||||
final urls = data
|
||||
.map((e) => e.values)
|
||||
.expand((e) => e)
|
||||
.map((e) => e.toString())
|
||||
.toList();
|
||||
return FlowyResult.success(urls);
|
||||
} else {
|
||||
return FlowyResult.failure(
|
||||
AIError.fromJson(json.decode(response.body)['error']),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return FlowyResult.failure(AIError(message: error.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> streamCompletion({
|
||||
String? objectId,
|
||||
required String text,
|
||||
required CompletionTypePB completionType,
|
||||
required Future<void> Function() onStart,
|
||||
required Future<void> Function(String text) onProcess,
|
||||
required Future<void> Function() onEnd,
|
||||
required void Function(AIError error) onError,
|
||||
}) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'select_model_bloc.freezed.dart';
|
||||
|
||||
class SelectModelBloc extends Bloc<SelectModelEvent, SelectModelState> {
|
||||
SelectModelBloc({
|
||||
required AIModelStateNotifier aiModelStateNotifier,
|
||||
}) : _aiModelStateNotifier = aiModelStateNotifier,
|
||||
super(SelectModelState.initial(aiModelStateNotifier)) {
|
||||
on<SelectModelEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
selectModel: (model) {
|
||||
AIEventUpdateSelectedModel(
|
||||
UpdateSelectedModelPB(
|
||||
source: _aiModelStateNotifier.objectId,
|
||||
selectedModel: model,
|
||||
),
|
||||
).send();
|
||||
|
||||
emit(state.copyWith(selectedModel: model));
|
||||
},
|
||||
didLoadModels: (models, selectedModel) {
|
||||
emit(
|
||||
SelectModelState(
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
_aiModelStateNotifier.addListener(
|
||||
onAvailableModelsChanged: _onAvailableModelsChanged,
|
||||
);
|
||||
}
|
||||
|
||||
final AIModelStateNotifier _aiModelStateNotifier;
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_aiModelStateNotifier.removeListener(
|
||||
onAvailableModelsChanged: _onAvailableModelsChanged,
|
||||
);
|
||||
await super.close();
|
||||
}
|
||||
|
||||
void _onAvailableModelsChanged(
|
||||
List<AIModelPB> models,
|
||||
AIModelPB? selectedModel,
|
||||
) {
|
||||
if (!isClosed) {
|
||||
add(SelectModelEvent.didLoadModels(models, selectedModel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SelectModelEvent with _$SelectModelEvent {
|
||||
const factory SelectModelEvent.selectModel(
|
||||
AIModelPB model,
|
||||
) = _SelectModel;
|
||||
|
||||
const factory SelectModelEvent.didLoadModels(
|
||||
List<AIModelPB> models,
|
||||
AIModelPB? selectedModel,
|
||||
) = _DidLoadModels;
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SelectModelState with _$SelectModelState {
|
||||
const factory SelectModelState({
|
||||
required List<AIModelPB> models,
|
||||
required AIModelPB? selectedModel,
|
||||
}) = _SelectModelState;
|
||||
|
||||
factory SelectModelState.initial(AIModelStateNotifier notifier) {
|
||||
final (models, selectedModel) = notifier.getAvailableModels();
|
||||
return SelectModelState(
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'text_completion.freezed.dart';
|
||||
part 'text_completion.g.dart';
|
||||
|
||||
@freezed
|
||||
class TextCompletionChoice with _$TextCompletionChoice {
|
||||
factory TextCompletionChoice({
|
||||
required String text,
|
||||
required int index,
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(name: 'finish_reason') String? finishReason,
|
||||
}) = _TextCompletionChoice;
|
||||
|
||||
factory TextCompletionChoice.fromJson(Map<String, Object?> json) =>
|
||||
_$TextCompletionChoiceFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class TextCompletionResponse with _$TextCompletionResponse {
|
||||
const factory TextCompletionResponse({
|
||||
required List<TextCompletionChoice> choices,
|
||||
}) = _TextCompletionResponse;
|
||||
|
||||
factory TextCompletionResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$TextCompletionResponseFromJson(json);
|
||||
}
|
||||
@@ -16,8 +16,7 @@ class AILoadingIndicator extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
return SelectionContainer.disabled(
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
child: SeparatedRow(
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'mentioned_page_text_span.dart';
|
||||
|
||||
class PromptInputTextField extends StatelessWidget {
|
||||
const PromptInputTextField({
|
||||
super.key,
|
||||
required this.cubit,
|
||||
required this.textController,
|
||||
required this.textFieldFocusNode,
|
||||
required this.contentPadding,
|
||||
this.hintText = "",
|
||||
});
|
||||
|
||||
final ChatInputControlCubit cubit;
|
||||
final TextEditingController textController;
|
||||
final FocusNode textFieldFocusNode;
|
||||
final EdgeInsetsGeometry contentPadding;
|
||||
final String hintText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExtendedTextField(
|
||||
controller: textController,
|
||||
focusNode: textFieldFocusNode,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: contentPadding,
|
||||
hintText: hintText,
|
||||
hintStyle: AIChatUILayout.inputHintTextStyle(context),
|
||||
isCollapsed: true,
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
minLines: 1,
|
||||
maxLines: null,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
specialTextSpanBuilder: PromptInputTextSpanBuilder(
|
||||
inputControlCubit: cubit,
|
||||
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+274
-159
@@ -1,53 +1,54 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
import 'package:flowy_infra/file_picker/file_picker_service.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
|
||||
class DesktopChatInput extends StatefulWidget {
|
||||
const DesktopChatInput({
|
||||
class DesktopPromptInput extends StatefulWidget {
|
||||
const DesktopPromptInput({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.isStreaming,
|
||||
required this.textController,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
this.hideDecoration = false,
|
||||
this.hideFormats = 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 bool hideFormats;
|
||||
final Widget? extraBottomActionButton;
|
||||
|
||||
@override
|
||||
State<DesktopChatInput> createState() => _DesktopChatInputState();
|
||||
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
|
||||
}
|
||||
|
||||
class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
class _DesktopPromptInputState extends State<DesktopPromptInput> {
|
||||
final textFieldKey = GlobalKey();
|
||||
final layerLink = LayerLink();
|
||||
final overlayController = OverlayPortalController();
|
||||
final inputControlCubit = ChatInputControlCubit();
|
||||
final focusNode = FocusNode();
|
||||
final textController = TextEditingController();
|
||||
|
||||
bool showPredefinedFormatSection = true;
|
||||
PredefinedFormat predefinedFormat = const PredefinedFormat(
|
||||
imageFormat: ImageFormat.text,
|
||||
textFormat: TextFormat.bulletList,
|
||||
);
|
||||
late SendButtonState sendButtonState;
|
||||
bool isComposing = false;
|
||||
|
||||
@@ -55,16 +56,19 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
textController.addListener(handleTextControllerChanged);
|
||||
|
||||
// refresh border color on focus change and hide menu when lost focus
|
||||
focusNode.addListener(
|
||||
() => setState(() {
|
||||
if (!focusNode.hasFocus) {
|
||||
cancelMentionPage();
|
||||
}
|
||||
}),
|
||||
);
|
||||
widget.textController.addListener(handleTextControllerChanged);
|
||||
focusNode
|
||||
..addListener(
|
||||
() {
|
||||
if (!widget.hideDecoration) {
|
||||
setState(() {}); // refresh border color
|
||||
}
|
||||
if (!focusNode.hasFocus) {
|
||||
cancelMentionPage(); // hide menu when lost focus
|
||||
}
|
||||
},
|
||||
)
|
||||
..onKeyEvent = handleKeyEvent;
|
||||
|
||||
updateSendButtonState();
|
||||
|
||||
@@ -82,7 +86,7 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
textController.dispose();
|
||||
widget.textController.removeListener(handleTextControllerChanged);
|
||||
inputControlCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -107,20 +111,12 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
overlayChildBuilder: (context) {
|
||||
return PromptInputMentionPageMenu(
|
||||
anchor: PromptInputAnchor(textFieldKey, layerLink),
|
||||
textController: textController,
|
||||
textController: widget.textController,
|
||||
onPageSelected: handlePageSelected,
|
||||
);
|
||||
},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: focusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: focusNode.hasFocus ? 1.5 : 1.0,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
|
||||
),
|
||||
decoration: decoration(context),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -132,7 +128,6 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
),
|
||||
child: TextFieldTapRegion(
|
||||
child: PromptInputFile(
|
||||
chatId: widget.chatId,
|
||||
onDeleted: (file) => context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.removeFile(file)),
|
||||
@@ -140,53 +135,65 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
constraints: getTextFieldConstraints(),
|
||||
child: inputTextField(),
|
||||
),
|
||||
if (showPredefinedFormatSection)
|
||||
Positioned.fill(
|
||||
bottom: null,
|
||||
child: TextFieldTapRegion(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsetsDirectional.only(start: 8.0),
|
||||
child: ChangeFormatBar(
|
||||
predefinedFormat: predefinedFormat,
|
||||
spacing: 4.0,
|
||||
onSelectPredefinedFormat: (format) {
|
||||
setState(() => predefinedFormat = format);
|
||||
},
|
||||
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return Stack(
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: getTextFieldConstraints(
|
||||
state.showPredefinedFormats && !widget.hideFormats,
|
||||
),
|
||||
child: inputTextField(),
|
||||
),
|
||||
if (state.showPredefinedFormats && !widget.hideFormats)
|
||||
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(
|
||||
showPredefinedFormatBar:
|
||||
state.showPredefinedFormats,
|
||||
showPredefinedFormatButton: !widget.hideFormats,
|
||||
onTogglePredefinedFormatSection: () =>
|
||||
context.read<AIPromptInputBloc>().add(
|
||||
AIPromptInputEvent
|
||||
.toggleShowPredefinedFormat(),
|
||||
),
|
||||
onStartMention: startMentionPageFromButton,
|
||||
sendButtonState: sendButtonState,
|
||||
onSendPressed: handleSend,
|
||||
onStopStreaming: widget.onStopStreaming,
|
||||
selectedSourcesNotifier:
|
||||
widget.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources:
|
||||
widget.onUpdateSelectedSources,
|
||||
extraBottomActionButton:
|
||||
widget.extraBottomActionButton,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
top: null,
|
||||
child: TextFieldTapRegion(
|
||||
child: _PromptBottomActions(
|
||||
textController: textController,
|
||||
overlayController: overlayController,
|
||||
focusNode: focusNode,
|
||||
showPredefinedFormats: showPredefinedFormatSection,
|
||||
predefinedFormat: predefinedFormat,
|
||||
onTogglePredefinedFormatSection: () {
|
||||
setState(() {
|
||||
showPredefinedFormatSection =
|
||||
!showPredefinedFormatSection;
|
||||
});
|
||||
},
|
||||
sendButtonState: sendButtonState,
|
||||
onSendPressed: handleSend,
|
||||
onStopStreaming: widget.onStopStreaming,
|
||||
onUpdateSelectedSources:
|
||||
widget.onUpdateSelectedSources,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -196,6 +203,40 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration decoration(BuildContext context) {
|
||||
if (widget.hideDecoration) {
|
||||
return BoxDecoration();
|
||||
}
|
||||
return BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: focusNode.hasFocus
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
width: focusNode.hasFocus ? 1.5 : 1.0,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
|
||||
);
|
||||
}
|
||||
|
||||
void startMentionPageFromButton() {
|
||||
if (overlayController.isShowing) {
|
||||
return;
|
||||
}
|
||||
if (!focusNode.hasFocus) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
widget.textController.text += '@';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
context
|
||||
.read<ChatInputControlCubit>()
|
||||
.startSearching(widget.textController.value);
|
||||
overlayController.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void cancelMentionPage() {
|
||||
if (overlayController.isShowing) {
|
||||
inputControlCubit.reset();
|
||||
@@ -206,7 +247,7 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
void updateSendButtonState() {
|
||||
if (widget.isStreaming) {
|
||||
sendButtonState = SendButtonState.streaming;
|
||||
} else if (textController.text.trim().isEmpty) {
|
||||
} else if (widget.textController.text.trim().isEmpty) {
|
||||
sendButtonState = SendButtonState.disabled;
|
||||
} else {
|
||||
sendButtonState = SendButtonState.enabled;
|
||||
@@ -218,9 +259,9 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
return;
|
||||
}
|
||||
final trimmedText = inputControlCubit.formatIntputText(
|
||||
textController.text.trim(),
|
||||
widget.textController.text.trim(),
|
||||
);
|
||||
textController.clear();
|
||||
widget.textController.clear();
|
||||
if (trimmedText.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -228,9 +269,13 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
// get the attached files and mentioned pages
|
||||
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
|
||||
widget.onSubmitted(
|
||||
trimmedText,
|
||||
showPredefinedFormatSection ? predefinedFormat : null,
|
||||
showPredefinedFormats ? predefinedFormat : null,
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
@@ -239,17 +284,17 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
setState(() {
|
||||
// update whether send button is clickable
|
||||
updateSendButtonState();
|
||||
isComposing = !textController.value.composing.isCollapsed;
|
||||
isComposing = !widget.textController.value.composing.isCollapsed;
|
||||
});
|
||||
|
||||
if (isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// handle text and selection changes ONLY when mentioning a page
|
||||
|
||||
// disable mention
|
||||
return;
|
||||
|
||||
// handle text and selection changes ONLY when mentioning a page
|
||||
// ignore: dead_code
|
||||
if (!overlayController.isShowing ||
|
||||
inputControlCubit.filterStartPosition == -1) {
|
||||
@@ -257,6 +302,7 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
}
|
||||
|
||||
// handle cases where mention a page is cancelled
|
||||
final textController = widget.textController;
|
||||
final textSelection = textController.value.selection;
|
||||
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
|
||||
final isCaretBeforeStartOfRange =
|
||||
@@ -303,22 +349,27 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
}
|
||||
|
||||
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event.character == '@') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
inputControlCubit.startSearching(textController.value);
|
||||
overlayController.show();
|
||||
});
|
||||
// if (event.character == '@') {
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// inputControlCubit.startSearching(widget.textController.value);
|
||||
// overlayController.show();
|
||||
// });
|
||||
// }
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
node.unfocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void handlePageSelected(ViewPB view) {
|
||||
final newText = textController.text.replaceRange(
|
||||
final newText = widget.textController.text.replaceRange(
|
||||
inputControlCubit.filterStartPosition,
|
||||
inputControlCubit.filterEndPosition,
|
||||
view.id,
|
||||
);
|
||||
textController.value = TextEditingValue(
|
||||
widget.textController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: inputControlCubit.filterStartPosition + view.id.length,
|
||||
@@ -330,18 +381,6 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
overlayController.hide();
|
||||
}
|
||||
|
||||
BoxConstraints getTextFieldConstraints() {
|
||||
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
|
||||
DesktopAIPromptSizes.actionBarSendButtonSize +
|
||||
DesktopAIChatSizes.inputActionBarMargin.vertical;
|
||||
double maxHeight = 300;
|
||||
if (showPredefinedFormatSection) {
|
||||
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
|
||||
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
|
||||
}
|
||||
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
|
||||
}
|
||||
|
||||
Widget inputTextField() {
|
||||
return Shortcuts(
|
||||
shortcuts: buildShortcuts(),
|
||||
@@ -351,17 +390,27 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
link: layerLink,
|
||||
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return PromptInputTextField(
|
||||
Widget textField = PromptInputTextField(
|
||||
key: textFieldKey,
|
||||
editable: state.editable,
|
||||
cubit: inputControlCubit,
|
||||
textController: textController,
|
||||
textController: widget.textController,
|
||||
textFieldFocusNode: focusNode,
|
||||
contentPadding: calculateContentPadding(),
|
||||
hintText: switch (state.aiType) {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
contentPadding:
|
||||
calculateContentPadding(state.showPredefinedFormats),
|
||||
hintText: state.hintText,
|
||||
);
|
||||
|
||||
if (!state.editable) {
|
||||
textField = FlowyTooltip(
|
||||
message: LocaleKeys
|
||||
.settings_aiPage_keys_localAINotReadyTextFieldPrompt
|
||||
.tr(),
|
||||
child: textField,
|
||||
);
|
||||
}
|
||||
|
||||
return textField;
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -369,8 +418,20 @@ class _DesktopChatInputState extends State<DesktopChatInput> {
|
||||
);
|
||||
}
|
||||
|
||||
EdgeInsetsGeometry calculateContentPadding() {
|
||||
final top = showPredefinedFormatSection
|
||||
BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) {
|
||||
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
|
||||
DesktopAIPromptSizes.actionBarSendButtonSize +
|
||||
DesktopAIChatSizes.inputActionBarMargin.vertical;
|
||||
double maxHeight = 300;
|
||||
if (showPredefinedFormats) {
|
||||
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
|
||||
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
|
||||
}
|
||||
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
|
||||
}
|
||||
|
||||
EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) {
|
||||
final top = showPredefinedFormats
|
||||
? DesktopAIPromptSizes.predefinedFormatButtonHeight
|
||||
: 0.0;
|
||||
final bottom = DesktopAIPromptSizes.actionBarSendButtonSize +
|
||||
@@ -451,30 +512,89 @@ class _FocusNextItemIntent extends Intent {
|
||||
const _FocusNextItemIntent();
|
||||
}
|
||||
|
||||
class _PromptBottomActions extends StatelessWidget {
|
||||
const _PromptBottomActions({
|
||||
class PromptInputTextField extends StatelessWidget {
|
||||
const PromptInputTextField({
|
||||
super.key,
|
||||
required this.editable,
|
||||
required this.cubit,
|
||||
required this.textController,
|
||||
required this.overlayController,
|
||||
required this.focusNode,
|
||||
required this.sendButtonState,
|
||||
required this.predefinedFormat,
|
||||
required this.onTogglePredefinedFormatSection,
|
||||
required this.showPredefinedFormats,
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
required this.onUpdateSelectedSources,
|
||||
required this.textFieldFocusNode,
|
||||
required this.contentPadding,
|
||||
this.hintText = "",
|
||||
});
|
||||
|
||||
final ChatInputControlCubit cubit;
|
||||
final TextEditingController textController;
|
||||
final OverlayPortalController overlayController;
|
||||
final FocusNode focusNode;
|
||||
final bool showPredefinedFormats;
|
||||
final PredefinedFormat predefinedFormat;
|
||||
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.showPredefinedFormatBar,
|
||||
required this.showPredefinedFormatButton,
|
||||
required this.onTogglePredefinedFormatSection,
|
||||
required this.onStartMention,
|
||||
required this.onSendPressed,
|
||||
required this.onStopStreaming,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
this.extraBottomActionButton,
|
||||
});
|
||||
|
||||
final bool showPredefinedFormatBar;
|
||||
final bool showPredefinedFormatButton;
|
||||
final void Function() onTogglePredefinedFormatSection;
|
||||
final void Function() onStartMention;
|
||||
final SendButtonState sendButtonState;
|
||||
final void Function() onSendPressed;
|
||||
final void Function() onStopStreaming;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
final Widget? extraBottomActionButton;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -483,18 +603,27 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
margin: DesktopAIChatSizes.inputActionBarMargin,
|
||||
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
if (state.chatState == null) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: _sendButton(),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
_predefinedFormatButton(),
|
||||
if (showPredefinedFormatButton) ...[
|
||||
_predefinedFormatButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
],
|
||||
SelectModelMenu(
|
||||
aiModelStateNotifier:
|
||||
context.read<AIPromptInputBloc>().aiModelStateNotifier,
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.aiType == AIType.appflowyAI) ...[
|
||||
_selectSourcesButton(context),
|
||||
if (state.aiType.isCloud) ...[
|
||||
_selectSourcesButton(),
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
],
|
||||
if (extraBottomActionButton != null) ...[
|
||||
extraBottomActionButton!,
|
||||
const HSpace(
|
||||
DesktopAIChatSizes.inputActionBarButtonSpacing,
|
||||
),
|
||||
@@ -519,15 +648,15 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
|
||||
Widget _predefinedFormatButton() {
|
||||
return PromptInputDesktopToggleFormatButton(
|
||||
showFormatBar: showPredefinedFormats,
|
||||
predefinedFormat: predefinedFormat,
|
||||
showFormatBar: showPredefinedFormatBar,
|
||||
onTap: onTogglePredefinedFormatSection,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _selectSourcesButton(BuildContext context) {
|
||||
Widget _selectSourcesButton() {
|
||||
return PromptInputDesktopSelectSourcesButton(
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
selectedSourcesNotifier: selectedSourcesNotifier,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -535,21 +664,7 @@ class _PromptBottomActions extends StatelessWidget {
|
||||
// return PromptInputMentionButton(
|
||||
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
|
||||
// buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
|
||||
// onTap: () {
|
||||
// if (overlayController.isShowing) {
|
||||
// return;
|
||||
// }
|
||||
// if (!focusNode.hasFocus) {
|
||||
// focusNode.requestFocus();
|
||||
// }
|
||||
// textController.text += '@';
|
||||
// Future.delayed(Duration.zero, () {
|
||||
// context
|
||||
// .read<ChatInputControlCubit>()
|
||||
// .startSearching(textController.value);
|
||||
// overlayController.show();
|
||||
// });
|
||||
// },
|
||||
// onTap: onStartMention,
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
@@ -13,11 +13,9 @@ import 'layout_define.dart';
|
||||
class PromptInputFile extends StatelessWidget {
|
||||
const PromptInputFile({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.onDeleted,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final void Function(ChatFile) onDeleted;
|
||||
|
||||
@override
|
||||
@@ -37,7 +35,6 @@ class PromptInputFile extends StatelessWidget {
|
||||
),
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) => ChatFilePreview(
|
||||
chatId: chatId,
|
||||
file: files[index],
|
||||
onDeleted: () => onDeleted(files[index]),
|
||||
),
|
||||
@@ -49,13 +46,11 @@ class PromptInputFile extends StatelessWidget {
|
||||
|
||||
class ChatFilePreview extends StatefulWidget {
|
||||
const ChatFilePreview({
|
||||
required this.chatId,
|
||||
required this.file,
|
||||
required this.onDeleted,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final ChatFile file;
|
||||
final VoidCallback onDeleted;
|
||||
|
||||
|
||||
+13
-8
@@ -1,24 +1,22 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../../service/ai_entities.dart';
|
||||
import 'layout_define.dart';
|
||||
|
||||
class PromptInputDesktopToggleFormatButton extends StatelessWidget {
|
||||
const PromptInputDesktopToggleFormatButton({
|
||||
super.key,
|
||||
required this.showFormatBar,
|
||||
required this.predefinedFormat,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool showFormatBar;
|
||||
final PredefinedFormat predefinedFormat;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -50,25 +48,30 @@ class ChangeFormatBar extends StatelessWidget {
|
||||
required this.predefinedFormat,
|
||||
required this.spacing,
|
||||
required this.onSelectPredefinedFormat,
|
||||
this.showImageFormats = true,
|
||||
});
|
||||
|
||||
final PredefinedFormat? predefinedFormat;
|
||||
final double spacing;
|
||||
final void Function(PredefinedFormat) onSelectPredefinedFormat;
|
||||
final bool showImageFormats;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showTextFormats = predefinedFormat?.imageFormat.hasText ?? true;
|
||||
return SizedBox(
|
||||
height: DesktopAIPromptSizes.predefinedFormatButtonHeight,
|
||||
child: SeparatedRow(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
separatorBuilder: () => HSpace(spacing),
|
||||
children: [
|
||||
_buildFormatButton(context, ImageFormat.text),
|
||||
_buildFormatButton(context, ImageFormat.textAndImage),
|
||||
_buildFormatButton(context, ImageFormat.image),
|
||||
if (predefinedFormat?.imageFormat.hasText ?? true) ...[
|
||||
_buildDivider(),
|
||||
if (showImageFormats) ...[
|
||||
_buildFormatButton(context, ImageFormat.text),
|
||||
_buildFormatButton(context, ImageFormat.textAndImage),
|
||||
_buildFormatButton(context, ImageFormat.image),
|
||||
],
|
||||
if (showImageFormats && showTextFormats) _buildDivider(),
|
||||
if (showTextFormats) ...[
|
||||
_buildTextFormatButton(context, TextFormat.paragraph),
|
||||
_buildTextFormatButton(context, TextFormat.bulletList),
|
||||
_buildTextFormatButton(context, TextFormat.numberedList),
|
||||
@@ -101,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget {
|
||||
},
|
||||
child: FlowyTooltip(
|
||||
message: format.i18n,
|
||||
preferBelow: false,
|
||||
child: SizedBox.square(
|
||||
dimension: _buttonSize,
|
||||
child: FlowyHover(
|
||||
@@ -147,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget {
|
||||
},
|
||||
child: FlowyTooltip(
|
||||
message: format.i18n,
|
||||
preferBelow: false,
|
||||
child: SizedBox.square(
|
||||
dimension: _buttonSize,
|
||||
child: FlowyHover(
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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,
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+53
-53
@@ -2,7 +2,6 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/base/drag_handler.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
@@ -19,9 +18,11 @@ import 'select_sources_menu.dart';
|
||||
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
|
||||
const PromptInputMobileSelectSourcesButton({
|
||||
super.key,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
@@ -36,15 +37,15 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
cubit.updateSelectedSources(
|
||||
context.read<ChatBloc>().state.selectedSourceIds,
|
||||
);
|
||||
onSelectedSourcesChanged();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
|
||||
cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -70,56 +71,49 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
],
|
||||
child: BlocBuilder<SpaceBloc, SpaceState>(
|
||||
builder: (context, state) {
|
||||
return BlocListener<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
cubit
|
||||
..updateSelectedSources(state.selectedSourceIds)
|
||||
..updateSelectedStatus();
|
||||
},
|
||||
child: FlowyButton(
|
||||
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
|
||||
expandText: false,
|
||||
text: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_page_s,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
size: const Size.square(20.0),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(10),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
await showMobileBottomSheet<void>(
|
||||
context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
maxChildSize: 0.98,
|
||||
enableDraggableScrollable: true,
|
||||
scrollableWidgetBuilder: (_, scrollController) {
|
||||
return Expanded(
|
||||
child: BlocProvider.value(
|
||||
value: cubit,
|
||||
child: _MobileSelectSourcesSheetBody(
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context) => const SizedBox.shrink(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
}
|
||||
},
|
||||
return FlowyButton(
|
||||
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
|
||||
expandText: false,
|
||||
text: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_page_s,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
size: const Size.square(20.0),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(10),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
await showMobileBottomSheet<void>(
|
||||
context,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
maxChildSize: 0.98,
|
||||
enableDraggableScrollable: true,
|
||||
scrollableWidgetBuilder: (_, scrollController) {
|
||||
return Expanded(
|
||||
child: BlocProvider.value(
|
||||
value: cubit,
|
||||
child: _MobileSelectSourcesSheetBody(
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
builder: (context) => const SizedBox.shrink(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -127,6 +121,12 @@ class _PromptInputMobileSelectSourcesButtonState
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onSelectedSourcesChanged() {
|
||||
cubit
|
||||
..updateSelectedSources(widget.selectedSourcesNotifier.value)
|
||||
..updateSelectedStatus();
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileSelectSourcesSheetBody extends StatefulWidget {
|
||||
|
||||
@@ -2,8 +2,8 @@ import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
@@ -24,9 +24,11 @@ import 'mention_page_menu.dart';
|
||||
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
|
||||
const PromptInputDesktopSelectSourcesButton({
|
||||
super.key,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
@@ -42,15 +44,15 @@ class _PromptInputDesktopSelectSourcesButtonState
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
cubit.updateSelectedSources(
|
||||
context.read<ChatBloc>().state.selectedSourceIds,
|
||||
);
|
||||
onSelectedSourcesChanged();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
|
||||
cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -76,51 +78,53 @@ class _PromptInputDesktopSelectSourcesButtonState
|
||||
],
|
||||
child: BlocBuilder<SpaceBloc, SpaceState>(
|
||||
builder: (context, state) {
|
||||
return BlocListener<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
cubit
|
||||
..updateSelectedSources(state.selectedSourceIds)
|
||||
..updateSelectedStatus();
|
||||
return AppFlowyPopover(
|
||||
constraints: BoxConstraints.loose(const Size(320, 380)),
|
||||
offset: const Offset(0.0, -10.0),
|
||||
direction: PopoverDirection.topWithCenterAligned,
|
||||
margin: EdgeInsets.zero,
|
||||
controller: popoverController,
|
||||
onOpen: () {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
child: AppFlowyPopover(
|
||||
constraints: BoxConstraints.loose(const Size(320, 380)),
|
||||
offset: const Offset(0.0, -10.0),
|
||||
direction: PopoverDirection.topWithCenterAligned,
|
||||
margin: EdgeInsets.zero,
|
||||
controller: popoverController,
|
||||
onOpen: () {
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
onClose: () {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: const _PopoverContent(),
|
||||
);
|
||||
},
|
||||
child: _IndicatorButton(
|
||||
onTap: () => popoverController.show(),
|
||||
),
|
||||
onClose: () {
|
||||
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
|
||||
context
|
||||
.read<ChatSettingsCubit>()
|
||||
.refreshSources(state.spaces, state.currentSpace);
|
||||
},
|
||||
popupBuilder: (_) {
|
||||
return BlocProvider.value(
|
||||
value: context.read<ChatSettingsCubit>(),
|
||||
child: const _PopoverContent(),
|
||||
);
|
||||
},
|
||||
child: _IndicatorButton(
|
||||
selectedSourcesNotifier: widget.selectedSourcesNotifier,
|
||||
onTap: () => popoverController.show(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onSelectedSourcesChanged() {
|
||||
cubit
|
||||
..updateSelectedSources(widget.selectedSourcesNotifier.value)
|
||||
..updateSelectedStatus();
|
||||
}
|
||||
}
|
||||
|
||||
class _IndicatorButton extends StatelessWidget {
|
||||
const _IndicatorButton({
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -141,14 +145,22 @@ class _IndicatorButton extends StatelessWidget {
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_page_s,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
const HSpace(2.0),
|
||||
BlocBuilder<ChatBloc, ChatState>(
|
||||
builder: (context, state) {
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedSourcesNotifier,
|
||||
builder: (context, selectedSourceIds, _) {
|
||||
final documentId =
|
||||
context.read<DocumentBloc?>()?.documentId;
|
||||
final label = documentId != null &&
|
||||
selectedSourceIds.length == 1 &&
|
||||
selectedSourceIds[0] == documentId
|
||||
? LocaleKeys.chat_currentPage.tr()
|
||||
: selectedSourceIds.length.toString();
|
||||
return FlowyText(
|
||||
state.selectedSourceIds.length.toString(),
|
||||
fontSize: 14,
|
||||
label,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
color: Theme.of(context).hintColor,
|
||||
);
|
||||
@@ -158,7 +170,7 @@ class _IndicatorButton extends StatelessWidget {
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(10),
|
||||
size: const Size.square(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
@@ -23,6 +25,23 @@ class PromptInputSendButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return FlowyIconButton(
|
||||
width: _buttonSize,
|
||||
richTooltipText: switch (state) {
|
||||
SendButtonState.streaming => TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${LocaleKeys.chat_stopTooltip.tr()} ',
|
||||
style: context.tooltipTextStyle(),
|
||||
),
|
||||
TextSpan(
|
||||
text: 'ESC',
|
||||
style: context
|
||||
.tooltipTextStyle()
|
||||
?.copyWith(color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
icon: switch (state) {
|
||||
SendButtonState.enabled => FlowySvg(
|
||||
FlowySvgs.ai_send_filled_s,
|
||||
|
||||
@@ -115,4 +115,9 @@ class KVKeys {
|
||||
///
|
||||
/// The value is a json string of [RecentIcons]
|
||||
static const String recentIcons = 'kRecentIcons';
|
||||
|
||||
/// The key for saving compact mode ids for node or databse view
|
||||
///
|
||||
/// The value is a json list of id
|
||||
static const String compactModeIds = 'compactModeIds';
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:open_filex/open_filex.dart';
|
||||
import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:url_launcher/url_launcher.dart' as launcher;
|
||||
|
||||
typedef OnFailureCallback = void Function(Uri uri);
|
||||
@@ -38,6 +39,11 @@ Future<bool> afLaunchUri(
|
||||
);
|
||||
}
|
||||
|
||||
// on Linux, add http scheme to the url if it is not present
|
||||
if (UniversalPlatform.isLinux && !isURL(url, {'require_protocol': true})) {
|
||||
uri = Uri.parse('https://$url');
|
||||
}
|
||||
|
||||
// try to launch the uri directly
|
||||
bool result;
|
||||
try {
|
||||
@@ -127,7 +133,6 @@ Future<bool> _afLaunchLocalUri(
|
||||
};
|
||||
if (context != null && context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: message,
|
||||
type: result.type == ResultType.done
|
||||
? ToastificationType.success
|
||||
|
||||
@@ -16,6 +16,7 @@ class AppFlowyConfiguration {
|
||||
required this.platform,
|
||||
required this.authenticator_type,
|
||||
required this.appflowy_cloud_config,
|
||||
required this.is_anon,
|
||||
required this.envs,
|
||||
});
|
||||
|
||||
@@ -28,6 +29,7 @@ class AppFlowyConfiguration {
|
||||
final String origin_app_path;
|
||||
final String device_id;
|
||||
final String platform;
|
||||
final bool is_anon;
|
||||
final int authenticator_type;
|
||||
final AppFlowyCloudConfiguration appflowy_cloud_config;
|
||||
final Map<String, String> envs;
|
||||
|
||||
+1
-17
@@ -23,9 +23,6 @@ import 'package:appflowy_backend/log.dart';
|
||||
///
|
||||
Future<void> _setAuthenticatorType(AuthenticatorType ty) async {
|
||||
switch (ty) {
|
||||
case AuthenticatorType.local:
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 0.toString());
|
||||
break;
|
||||
case AuthenticatorType.appflowyCloud:
|
||||
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 2.toString());
|
||||
break;
|
||||
@@ -63,8 +60,6 @@ Future<AuthenticatorType> getAuthenticatorType() async {
|
||||
}
|
||||
|
||||
switch (value ?? "0") {
|
||||
case "0":
|
||||
return AuthenticatorType.local;
|
||||
case "2":
|
||||
return AuthenticatorType.appflowyCloud;
|
||||
case "3":
|
||||
@@ -106,14 +101,11 @@ bool get isAppFlowyCloudEnabled {
|
||||
}
|
||||
|
||||
enum AuthenticatorType {
|
||||
local,
|
||||
appflowyCloud,
|
||||
appflowyCloudSelfHost,
|
||||
// The 'appflowyCloudDevelop' type is used for develop purposes only.
|
||||
appflowyCloudDevelop;
|
||||
|
||||
bool get isLocal => this == AuthenticatorType.local;
|
||||
|
||||
bool get isAppFlowyCloudEnabled =>
|
||||
this == AuthenticatorType.appflowyCloudSelfHost ||
|
||||
this == AuthenticatorType.appflowyCloudDevelop ||
|
||||
@@ -121,8 +113,6 @@ enum AuthenticatorType {
|
||||
|
||||
int get value {
|
||||
switch (this) {
|
||||
case AuthenticatorType.local:
|
||||
return 0;
|
||||
case AuthenticatorType.appflowyCloud:
|
||||
return 2;
|
||||
case AuthenticatorType.appflowyCloudSelfHost:
|
||||
@@ -134,8 +124,6 @@ enum AuthenticatorType {
|
||||
|
||||
static AuthenticatorType fromValue(int value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return AuthenticatorType.local;
|
||||
case 2:
|
||||
return AuthenticatorType.appflowyCloud;
|
||||
case 3:
|
||||
@@ -143,7 +131,7 @@ enum AuthenticatorType {
|
||||
case 4:
|
||||
return AuthenticatorType.appflowyCloudDevelop;
|
||||
default:
|
||||
return AuthenticatorType.local;
|
||||
return AuthenticatorType.appflowyCloud;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,10 +164,6 @@ Future<void> useAppFlowyBetaCloudWithURL(
|
||||
await _setAppFlowyCloudUrl(url);
|
||||
}
|
||||
|
||||
Future<void> useLocalServer() async {
|
||||
await _setAuthenticatorType(AuthenticatorType.local);
|
||||
}
|
||||
|
||||
// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
|
||||
class AppFlowyCloudSharedEnv {
|
||||
AppFlowyCloudSharedEnv({
|
||||
|
||||
@@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0;
|
||||
|
||||
const double _kDefaultHorizontalPadding = 12.0;
|
||||
|
||||
typedef CompareFunction<T> = bool Function(T? left, T? right);
|
||||
|
||||
// Navigation shortcuts to move the selected menu items up or down.
|
||||
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
|
||||
<ShortcutActivator, Intent>{
|
||||
@@ -86,6 +88,7 @@ class AFDropdownMenu<T> extends StatefulWidget {
|
||||
this.requestFocusOnTap,
|
||||
this.expandedInsets,
|
||||
this.searchCallback,
|
||||
this.selectOptionCompare,
|
||||
required this.dropdownMenuEntries,
|
||||
});
|
||||
|
||||
@@ -267,6 +270,11 @@ class AFDropdownMenu<T> extends StatefulWidget {
|
||||
/// which contains the contents of the text input field.
|
||||
final SearchCallback<T>? searchCallback;
|
||||
|
||||
/// Defines the compare function for the menu items.
|
||||
///
|
||||
/// Defaults to null. If this is null, the menu items will be sorted by the label.
|
||||
final CompareFunction<T>? selectOptionCompare;
|
||||
|
||||
@override
|
||||
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
|
||||
}
|
||||
@@ -301,7 +309,16 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
|
||||
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
|
||||
|
||||
final int index = filteredEntries.indexWhere(
|
||||
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
|
||||
(DropdownMenuEntry<T> entry) {
|
||||
if (widget.selectOptionCompare != null) {
|
||||
return widget.selectOptionCompare!(
|
||||
entry.value,
|
||||
widget.initialSelection,
|
||||
);
|
||||
} else {
|
||||
return entry.value == widget.initialSelection;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (index != -1) {
|
||||
_textEditingController.value = TextEditingValue(
|
||||
|
||||
@@ -336,7 +336,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
listener: (context, state) {
|
||||
if (state.isLocked) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.lockPage_pageLockedToast.tr(),
|
||||
);
|
||||
|
||||
@@ -366,7 +365,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
listener: (context, state) {
|
||||
if (state.isLocked) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.lockPage_pageLockedToast.tr(),
|
||||
);
|
||||
}
|
||||
@@ -387,8 +385,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
|
||||
bottom: 4.0,
|
||||
),
|
||||
child: FlowySvg(
|
||||
FlowySvgs.lock_page_s,
|
||||
color: const Color(0xFFD95A0B),
|
||||
FlowySvgs.lock_page_fill_s,
|
||||
blendMode: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+1
-12
@@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
context.pop();
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_duplicateSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
@@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
_toggleFavorite(context);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_favoriteSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
@@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
_toggleFavorite(context);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
|
||||
);
|
||||
}
|
||||
@@ -202,8 +199,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copy.tr(),
|
||||
message: LocaleKeys.message_copy_success.tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
|
||||
);
|
||||
} else {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
@@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
if (state.publishResult != null) {
|
||||
state.publishResult!.fold(
|
||||
(value) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_publishSuccessfully.tr(),
|
||||
),
|
||||
(error) => showToastNotification(
|
||||
context,
|
||||
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
|
||||
type: ToastificationType.error,
|
||||
),
|
||||
@@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
} else if (state.unpublishResult != null) {
|
||||
state.unpublishResult!.fold(
|
||||
(value) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
|
||||
),
|
||||
(error) => showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.publish_unpublishFailed.tr(),
|
||||
description: error.msg,
|
||||
type: ToastificationType.error,
|
||||
@@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
state.updatePathNameResult!.onSuccess(
|
||||
(value) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message:
|
||||
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
|
||||
);
|
||||
|
||||
-3
@@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
|
||||
Navigator.pop(context);
|
||||
context.read<ViewBloc>().add(const ViewEvent.duplicate());
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_duplicateSuccessfully.tr(),
|
||||
);
|
||||
break;
|
||||
@@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
|
||||
.read<FavoriteBloc>()
|
||||
.add(FavoriteEvent.toggle(widget.view));
|
||||
showToastNotification(
|
||||
context,
|
||||
message: !widget.view.isFavorite
|
||||
? LocaleKeys.button_favoriteSuccessfully.tr()
|
||||
: LocaleKeys.button_unfavoriteSuccessfully.tr(),
|
||||
@@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
|
||||
Navigator.pop(context);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.sideBar_removeSuccess.tr(),
|
||||
);
|
||||
},
|
||||
|
||||
-2
@@ -45,7 +45,6 @@ enum MobilePaneActionType {
|
||||
size: 24.0,
|
||||
onPressed: (context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
|
||||
);
|
||||
|
||||
@@ -61,7 +60,6 @@ enum MobilePaneActionType {
|
||||
size: 24.0,
|
||||
onPressed: (context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.button_favoriteSuccessfully.tr(),
|
||||
);
|
||||
|
||||
|
||||
+6
-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,
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
|
||||
Log.info('Open row page(${widget.documentId})');
|
||||
|
||||
if (view == null) {
|
||||
showToastNotification(context, message: 'Failed to open row page');
|
||||
showToastNotification(message: 'Failed to open row page');
|
||||
// reload the view again
|
||||
unawaited(_preloadView(context));
|
||||
Log.error('Failed to open row page(${widget.documentId})');
|
||||
|
||||
@@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> {
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
showToastNotification(context, message: message, type: toastType);
|
||||
showToastNotification(message: message, type: toastType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
|
||||
key: ValueKey(currentWorkspaceId),
|
||||
userProfile: userProfile,
|
||||
workspaceId: currentWorkspaceId,
|
||||
currentWorkspaceMemberRole: state.currentWorkspace?.role,
|
||||
),
|
||||
const SupportSettingGroup(),
|
||||
const AboutSettingGroup(),
|
||||
|
||||
+15
-1
@@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem {
|
||||
members,
|
||||
trash,
|
||||
help,
|
||||
helpAndDocumentation,
|
||||
}
|
||||
|
||||
class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
@@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
|
||||
),
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
|
||||
svg: FlowySvgs.help_and_documentation_s,
|
||||
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
|
||||
),
|
||||
const PopupMenuDivider(height: 0.5),
|
||||
_buildItem(
|
||||
value: _MobileSettingsPopupMenuItem.help,
|
||||
svg: FlowySvgs.message_support_s,
|
||||
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
|
||||
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
|
||||
),
|
||||
],
|
||||
onSelected: (_MobileSettingsPopupMenuItem value) {
|
||||
@@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
case _MobileSettingsPopupMenuItem.help:
|
||||
_openHelpPage(context);
|
||||
break;
|
||||
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
|
||||
_openHelpAndDocumentationPage(context);
|
||||
break;
|
||||
}
|
||||
},
|
||||
child: const Padding(
|
||||
@@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
|
||||
void _openSettingsPage(BuildContext context) {
|
||||
context.push(MobileHomeSettingPage.routeName);
|
||||
}
|
||||
|
||||
void _openHelpAndDocumentationPage(BuildContext context) {
|
||||
afLaunchUrlString('https://appflowy.com/guide');
|
||||
}
|
||||
}
|
||||
|
||||
class _PopupButton extends StatelessWidget {
|
||||
|
||||
@@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
|
||||
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_duplicateSpace.tr(),
|
||||
);
|
||||
|
||||
@@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
|
||||
.add(SpaceEvent.rename(space: widget.space, name: name));
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_renameSpace.tr(),
|
||||
);
|
||||
},
|
||||
@@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
|
||||
);
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_updateSpace.tr(),
|
||||
);
|
||||
|
||||
@@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
|
||||
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.space_success_deleteSpace.tr(),
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -102,7 +102,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasIcon = item.icon != null;
|
||||
final hasIcon = item.iconBuilder != null;
|
||||
return Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
@@ -119,7 +119,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasIcon) ...[
|
||||
item.icon!.call(isSelected),
|
||||
item.iconBuilder!.call(isSelected),
|
||||
SizedBox(width: 12),
|
||||
],
|
||||
Flexible(
|
||||
|
||||
@@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_notifications_markAsReadNotifications_allSuccess
|
||||
.tr(),
|
||||
@@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget {
|
||||
}
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
|
||||
.tr(),
|
||||
);
|
||||
|
||||
-3
@@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
|
||||
|
||||
void _onMarkAllAsRead(BuildContext context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_notifications_markAsReadNotifications_allSuccess
|
||||
.tr(),
|
||||
@@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
|
||||
|
||||
void _onArchiveAll(BuildContext context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
|
||||
.tr(),
|
||||
);
|
||||
@@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
|
||||
}
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: 'Unarchive all success (Debug Mode)',
|
||||
);
|
||||
|
||||
|
||||
-4
@@ -31,7 +31,6 @@ enum NotificationPaneActionType {
|
||||
size: 24.0,
|
||||
onPressed: (context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_notifications_markAsReadNotifications_success
|
||||
.tr(),
|
||||
@@ -55,7 +54,6 @@ enum NotificationPaneActionType {
|
||||
size: 24.0,
|
||||
onPressed: (context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: 'Unarchive notification success',
|
||||
);
|
||||
|
||||
@@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
|
||||
.tr(),
|
||||
);
|
||||
@@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget {
|
||||
|
||||
void _onArchive(BuildContext context) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_archiveNotifications_success
|
||||
.tr()
|
||||
.tr(),
|
||||
|
||||
@@ -74,7 +74,6 @@ class _NotificationTabState extends State<NotificationTab>
|
||||
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
+113
-54
@@ -36,12 +36,16 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
Alignment _alignment = Alignment.topLeft;
|
||||
final int itemCountFilter;
|
||||
final int startOffset;
|
||||
ValueNotifier<_Position> _positionNotifier = ValueNotifier(_Position.zero);
|
||||
|
||||
@override
|
||||
void dismiss() {
|
||||
if (_selectionMenuEntry != null) {
|
||||
editorState.service.keyboardService?.enable();
|
||||
editorState.service.scrollService?.enable();
|
||||
editorState
|
||||
.removeScrollViewScrolledListener(_checkPositionAfterScrolling);
|
||||
_positionNotifier.dispose();
|
||||
}
|
||||
|
||||
_selectionMenuEntry?.remove();
|
||||
@@ -53,23 +57,20 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
final completer = Completer<void>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
_show();
|
||||
editorState.addScrollViewScrolledListener(_checkPositionAfterScrolling);
|
||||
completer.complete();
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void _show() {
|
||||
final selectionRects = editorState.selectionRects();
|
||||
if (selectionRects.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final (left, top, right, bottom) = getPosition();
|
||||
final position = _getCurrentPosition();
|
||||
if (position == null) return;
|
||||
|
||||
final editorHeight = editorState.renderBox!.size.height;
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
|
||||
_positionNotifier = ValueNotifier(position);
|
||||
_selectionMenuEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return SizedBox(
|
||||
@@ -80,47 +81,54 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
onTap: dismiss,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
left: left,
|
||||
right: right,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: MobileSelectionMenuWidget(
|
||||
selectionMenuStyle: style,
|
||||
singleColumn: singleColumn,
|
||||
items: selectionMenuItems
|
||||
..forEach((element) {
|
||||
if (element is MobileSelectionMenuItem) {
|
||||
element.deleteSlash = false;
|
||||
element.deleteKeywords = deleteKeywordsByDefault;
|
||||
for (final e in element.children) {
|
||||
e.deleteSlash = deleteSlashByDefault;
|
||||
e.deleteKeywords = deleteKeywordsByDefault;
|
||||
e.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
} else {
|
||||
element.deleteSlash = deleteSlashByDefault;
|
||||
element.deleteKeywords = deleteKeywordsByDefault;
|
||||
element.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
}),
|
||||
maxItemInRow: 5,
|
||||
editorState: editorState,
|
||||
itemCountFilter: itemCountFilter,
|
||||
startOffset: startOffset,
|
||||
menuService: this,
|
||||
onExit: () {
|
||||
dismiss();
|
||||
},
|
||||
deleteSlashByDefault: deleteSlashByDefault,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _positionNotifier,
|
||||
builder: (context, value, _) {
|
||||
return Positioned(
|
||||
top: value.top,
|
||||
bottom: value.bottom,
|
||||
left: value.left,
|
||||
right: value.right,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: MobileSelectionMenuWidget(
|
||||
selectionMenuStyle: style,
|
||||
singleColumn: singleColumn,
|
||||
items: selectionMenuItems
|
||||
..forEach((element) {
|
||||
if (element is MobileSelectionMenuItem) {
|
||||
element.deleteSlash = false;
|
||||
element.deleteKeywords =
|
||||
deleteKeywordsByDefault;
|
||||
for (final e in element.children) {
|
||||
e.deleteSlash = deleteSlashByDefault;
|
||||
e.deleteKeywords = deleteKeywordsByDefault;
|
||||
e.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
} else {
|
||||
element.deleteSlash = deleteSlashByDefault;
|
||||
element.deleteKeywords =
|
||||
deleteKeywordsByDefault;
|
||||
element.onSelected = () {
|
||||
dismiss();
|
||||
};
|
||||
}
|
||||
}),
|
||||
maxItemInRow: 5,
|
||||
editorState: editorState,
|
||||
itemCountFilter: itemCountFilter,
|
||||
startOffset: startOffset,
|
||||
menuService: this,
|
||||
onExit: () {
|
||||
dismiss();
|
||||
},
|
||||
deleteSlashByDefault: deleteSlashByDefault,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -135,6 +143,34 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
editorState.service.scrollService?.disable();
|
||||
}
|
||||
|
||||
/// the workaround for: editor auto scrolling that will cause wrong position
|
||||
/// of slash menu
|
||||
void _checkPositionAfterScrolling() {
|
||||
final position = _getCurrentPosition();
|
||||
if (position == null) return;
|
||||
if (position == _positionNotifier.value) {
|
||||
Future.delayed(const Duration(milliseconds: 100)).then((_) {
|
||||
final position = _getCurrentPosition();
|
||||
if (position == null) return;
|
||||
if (position != _positionNotifier.value) {
|
||||
_positionNotifier.value = position;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_positionNotifier.value = position;
|
||||
}
|
||||
}
|
||||
|
||||
_Position? _getCurrentPosition() {
|
||||
final selectionRects = editorState.selectionRects();
|
||||
if (selectionRects.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final (left, top, right, bottom) = getPosition();
|
||||
return _Position(left, top, right, bottom);
|
||||
}
|
||||
|
||||
@override
|
||||
Alignment get alignment {
|
||||
return _alignment;
|
||||
@@ -166,7 +202,6 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
bottom = offset.dy;
|
||||
break;
|
||||
}
|
||||
|
||||
return (left, top, right, bottom);
|
||||
}
|
||||
|
||||
@@ -186,8 +221,9 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
final bottomRight = rect.bottomRight;
|
||||
final topRight = rect.topRight;
|
||||
var offset = bottomRight + menuOffset;
|
||||
final limitX = editorWidth - menuWidth + editorOffset.dx;
|
||||
_offset = Offset(
|
||||
offset.dx,
|
||||
min(offset.dx, limitX),
|
||||
offset.dy,
|
||||
);
|
||||
|
||||
@@ -196,10 +232,9 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
offset = topRight - menuOffset;
|
||||
_alignment = Alignment.bottomLeft;
|
||||
|
||||
final limitX = editorWidth - menuWidth;
|
||||
_offset = Offset(
|
||||
min(offset.dx, limitX),
|
||||
MediaQuery.of(context).size.height - offset.dy,
|
||||
offset.dx,
|
||||
editorOffset.dy + editorHeight - offset.dy,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +245,6 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
: Alignment.bottomRight;
|
||||
|
||||
final x = editorWidth - _offset.dx + editorOffset.dx;
|
||||
final limitX = editorWidth - menuWidth + editorOffset.dx;
|
||||
_offset = Offset(
|
||||
min(x, limitX),
|
||||
_offset.dy,
|
||||
@@ -218,3 +252,28 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Position {
|
||||
const _Position(this.left, this.top, this.right, this.bottom);
|
||||
|
||||
final double? left;
|
||||
final double? top;
|
||||
final double? right;
|
||||
final double? bottom;
|
||||
|
||||
static const _Position zero = _Position(0, 0, 0, 0);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is _Position &&
|
||||
runtimeType == other.runtimeType &&
|
||||
left == other.left &&
|
||||
top == other.top &&
|
||||
right == other.right &&
|
||||
bottom == other.bottom;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
left.hashCode ^ top.hashCode ^ right.hashCode ^ bottom.hashCode;
|
||||
}
|
||||
+28
-3
@@ -36,9 +36,7 @@ class MobileSelectionMenuItemWidget extends StatelessWidget {
|
||||
),
|
||||
style: ButtonStyle(
|
||||
alignment: Alignment.centerLeft,
|
||||
overlayColor: WidgetStateProperty.all(
|
||||
style.selectionMenuItemSelectedColor,
|
||||
),
|
||||
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
backgroundColor: isSelected
|
||||
? WidgetStateProperty.all(
|
||||
style.selectionMenuItemSelectedColor,
|
||||
@@ -86,6 +84,15 @@ class MobileSelectionMenuStyle extends SelectionMenuStyle {
|
||||
required super.selectionMenuItemSelectedTextColor,
|
||||
required super.selectionMenuItemSelectedIconColor,
|
||||
required super.selectionMenuItemSelectedColor,
|
||||
required super.selectionMenuUnselectedLabelColor,
|
||||
required super.selectionMenuDividerColor,
|
||||
required super.selectionMenuLinkBorderColor,
|
||||
required super.selectionMenuInvalidLinkColor,
|
||||
required super.selectionMenuButtonColor,
|
||||
required super.selectionMenuButtonTextColor,
|
||||
required super.selectionMenuButtonIconColor,
|
||||
required super.selectionMenuButtonBorderColor,
|
||||
required super.selectionMenuTabIndicatorColor,
|
||||
required this.selectionMenuItemRightIconColor,
|
||||
});
|
||||
|
||||
@@ -99,6 +106,15 @@ class MobileSelectionMenuStyle extends SelectionMenuStyle {
|
||||
selectionMenuItemRightIconColor: Color(0xB31E2022),
|
||||
selectionMenuItemSelectedTextColor: Color.fromARGB(255, 56, 91, 247),
|
||||
selectionMenuItemSelectedIconColor: Color.fromARGB(255, 56, 91, 247),
|
||||
selectionMenuUnselectedLabelColor: Color(0xFF333333),
|
||||
selectionMenuDividerColor: Color(0xFF00BCF0),
|
||||
selectionMenuLinkBorderColor: Color(0xFF00BCF0),
|
||||
selectionMenuInvalidLinkColor: Color(0xFFE53935),
|
||||
selectionMenuButtonColor: Color(0xFF00BCF0),
|
||||
selectionMenuButtonTextColor: Color(0xFF333333),
|
||||
selectionMenuButtonIconColor: Color(0xFF333333),
|
||||
selectionMenuButtonBorderColor: Color(0xFF00BCF0),
|
||||
selectionMenuTabIndicatorColor: Color(0xFF00BCF0),
|
||||
);
|
||||
|
||||
static const MobileSelectionMenuStyle dark = MobileSelectionMenuStyle(
|
||||
@@ -109,5 +125,14 @@ class MobileSelectionMenuStyle extends SelectionMenuStyle {
|
||||
selectionMenuItemRightIconColor: Color(0xB3FFFFFF),
|
||||
selectionMenuItemSelectedTextColor: Color(0xFF131720),
|
||||
selectionMenuItemSelectedIconColor: Color(0xFF131720),
|
||||
selectionMenuUnselectedLabelColor: Color(0xFFBBC3CD),
|
||||
selectionMenuDividerColor: Color(0xFF3A3F44),
|
||||
selectionMenuLinkBorderColor: Color(0xFF3A3F44),
|
||||
selectionMenuInvalidLinkColor: Color(0xFFE53935),
|
||||
selectionMenuButtonColor: Color(0xFF00BCF0),
|
||||
selectionMenuButtonTextColor: Color(0xFFFFFFFF),
|
||||
selectionMenuButtonIconColor: Color(0xFFFFFFFF),
|
||||
selectionMenuButtonBorderColor: Color(0xFF00BCF0),
|
||||
selectionMenuTabIndicatorColor: Color(0xFF00BCF0),
|
||||
);
|
||||
}
|
||||
+27
-8
@@ -1,6 +1,8 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'mobile_selection_menu_item.dart';
|
||||
@@ -314,15 +316,32 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
|
||||
}
|
||||
|
||||
Widget _buildNoResultsWidget(BuildContext context) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 140,
|
||||
child: Material(
|
||||
child: Text(
|
||||
"No results",
|
||||
style: TextStyle(fontSize: 18.0, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
width: 240,
|
||||
height: 48,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Center(
|
||||
child: Text(
|
||||
LocaleKeys.inlineActions_noResults.tr(),
|
||||
style: TextStyle(fontSize: 18.0, color: Color(0x801F2225)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+2
-2
@@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
|
||||
),
|
||||
MobileSettingItem(
|
||||
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
|
||||
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
|
||||
),
|
||||
if (kDebugMode)
|
||||
MobileSettingItem(
|
||||
|
||||
+11
-13
@@ -5,8 +5,6 @@ import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
|
||||
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,12 +16,10 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
super.key,
|
||||
required this.userProfile,
|
||||
required this.workspaceId,
|
||||
this.currentWorkspaceMemberRole,
|
||||
});
|
||||
|
||||
final UserProfilePB userProfile;
|
||||
final String workspaceId;
|
||||
final AFRolePB? currentWorkspaceMemberRole;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -32,7 +28,6 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
create: (context) => SettingsAIBloc(
|
||||
userProfile,
|
||||
workspaceId,
|
||||
currentWorkspaceMemberRole,
|
||||
)..add(const SettingsAIEvent.started()),
|
||||
child: BlocBuilder<SettingsAIBloc, SettingsAIState>(
|
||||
builder: (context, state) {
|
||||
@@ -48,7 +43,7 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
children: [
|
||||
Flexible(
|
||||
child: FlowyText(
|
||||
state.selectedAIModel,
|
||||
state.availableModels?.selectedModel.name ?? "",
|
||||
color: theme.colorScheme.onSurface,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -84,16 +79,19 @@ class AiSettingsGroup extends StatelessWidget {
|
||||
title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
|
||||
builder: (_) {
|
||||
return Column(
|
||||
children: availableModels
|
||||
.mapIndexed(
|
||||
(index, model) => FlowyOptionTile.checkbox(
|
||||
text: model,
|
||||
showTopBorder: index == 0,
|
||||
isSelected: state.selectedAIModel == model,
|
||||
children: (availableModels?.models ?? [])
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => FlowyOptionTile.checkbox(
|
||||
text: entry.value.name,
|
||||
showTopBorder: entry.key == 0,
|
||||
isSelected:
|
||||
availableModels?.selectedModel.name == entry.value.name,
|
||||
onTap: () {
|
||||
context
|
||||
.read<SettingsAIBloc>()
|
||||
.add(SettingsAIEvent.selectModel(model));
|
||||
.add(SettingsAIEvent.selectModel(entry.value));
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,6 +19,7 @@ class AppFlowyCloudPage extends StatelessWidget {
|
||||
),
|
||||
body: SettingCloud(
|
||||
restartAppFlowy: () async {
|
||||
await getIt<AuthService>().signOut();
|
||||
await runAppFlowy();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
-33
@@ -1,9 +1,7 @@
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/user/application/auth/auth_service.dart';
|
||||
import 'package:appflowy/user/application/sign_in_bloc.dart';
|
||||
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
|
||||
@@ -32,12 +30,6 @@ class UserSessionSettingGroup extends StatelessWidget {
|
||||
if (showThirdPartyLogin) _buildThirdPartySignInButtons(context),
|
||||
const VSpace(8.0),
|
||||
|
||||
// logout button
|
||||
MobileLogoutButton(
|
||||
text: LocaleKeys.settings_menu_logout.tr(),
|
||||
onPressed: () async => _showLogoutDialog(),
|
||||
),
|
||||
|
||||
// delete account button
|
||||
// only show the delete account button in cloud mode
|
||||
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[
|
||||
@@ -79,31 +71,6 @@ class UserSessionSettingGroup extends StatelessWidget {
|
||||
builder: (_) => const _DeleteAccountBottomSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showLogoutDialog() async {
|
||||
return showFlowyCupertinoConfirmDialog(
|
||||
title: LocaleKeys.settings_menu_logoutPrompt.tr(),
|
||||
leftButton: FlowyText(
|
||||
LocaleKeys.button_cancel.tr(),
|
||||
fontSize: 17.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF007AFF),
|
||||
),
|
||||
rightButton: FlowyText(
|
||||
LocaleKeys.button_logout.tr(),
|
||||
fontSize: 17.0,
|
||||
figmaLineHeight: 24.0,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFFFE0220),
|
||||
),
|
||||
onRightButtonPressed: (context) async {
|
||||
Navigator.of(context).pop();
|
||||
await getIt<AuthService>().signOut();
|
||||
await runAppFlowy();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeleteAccountBottomSheet extends StatefulWidget {
|
||||
|
||||
+2
-8
@@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
result.fold(
|
||||
(s) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message:
|
||||
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
|
||||
bottomPadding: keyboardHeight,
|
||||
@@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
|
||||
});
|
||||
showToastNotification(
|
||||
context,
|
||||
type: ToastificationType.error,
|
||||
bottomPadding: keyboardHeight,
|
||||
message: message,
|
||||
@@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
result.fold(
|
||||
(s) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message:
|
||||
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
|
||||
bottomPadding: keyboardHeight,
|
||||
@@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
|
||||
});
|
||||
showToastNotification(
|
||||
context,
|
||||
type: ToastificationType.error,
|
||||
message: message,
|
||||
bottomPadding: keyboardHeight,
|
||||
@@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
result.fold(
|
||||
(s) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys
|
||||
.settings_appearance_members_removeFromWorkspaceSuccess
|
||||
.tr(),
|
||||
@@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
},
|
||||
(f) {
|
||||
showToastNotification(
|
||||
context,
|
||||
type: ToastificationType.error,
|
||||
message: LocaleKeys
|
||||
.settings_appearance_members_removeFromWorkspaceFailed
|
||||
@@ -282,11 +276,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
|
||||
void _inviteMember(BuildContext context) {
|
||||
final email = emailController.text;
|
||||
if (!isEmail(email)) {
|
||||
return showToastNotification(
|
||||
context,
|
||||
showToastNotification(
|
||||
type: ToastificationType.error,
|
||||
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context
|
||||
.read<WorkspaceMemberBloc>()
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
|
||||
import 'package:appflowy_backend/rust_stream.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
|
||||
typedef OnUpdateSelectedModel = void Function(AIModelPB model);
|
||||
|
||||
class AIModelSwitchListener {
|
||||
AIModelSwitchListener({required this.objectId}) {
|
||||
_parser = ChatNotificationParser(id: objectId, callback: _callback);
|
||||
_subscription = RustStreamReceiver.listen(
|
||||
(observable) => _parser?.parse(observable),
|
||||
);
|
||||
}
|
||||
|
||||
final String objectId;
|
||||
StreamSubscription<SubscribeObject>? _subscription;
|
||||
ChatNotificationParser? _parser;
|
||||
|
||||
void start({
|
||||
OnUpdateSelectedModel? onUpdateSelectedModel,
|
||||
}) {
|
||||
this.onUpdateSelectedModel = onUpdateSelectedModel;
|
||||
}
|
||||
|
||||
OnUpdateSelectedModel? onUpdateSelectedModel;
|
||||
|
||||
void _callback(
|
||||
ChatNotification ty,
|
||||
FlowyResult<Uint8List, FlowyError> result,
|
||||
) {
|
||||
result.map((r) {
|
||||
switch (ty) {
|
||||
case ChatNotification.DidUpdateSelectedModel:
|
||||
onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
}
|
||||
}
|
||||
+124
-126
@@ -23,11 +23,126 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
parseMetadata(refSourceJsonString),
|
||||
),
|
||||
) {
|
||||
_dispatch();
|
||||
_registerEventHandlers();
|
||||
_initializeStreamListener();
|
||||
_checkInitialStreamState();
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _registerEventHandlers() {
|
||||
on<_UpdateText>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: event.text,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_ReceiveError>((event, emit) {
|
||||
emit(state.copyWith(messageState: MessageState.onError(event.error)));
|
||||
});
|
||||
|
||||
on<_Retry>((event, emit) async {
|
||||
if (questionId == null) {
|
||||
Log.error("Question id is not valid: $questionId");
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(messageState: const MessageState.loading()));
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: questionId,
|
||||
);
|
||||
final result = await AIEventGetAnswerForQuestion(payload).send();
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(answer) => add(ChatAIMessageEvent.retryResult(answer.content)),
|
||||
(err) {
|
||||
Log.error("Failed to get answer: $err");
|
||||
add(ChatAIMessageEvent.receiveError(err.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
on<_RetryResult>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: event.text,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIResponseLimit>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIResponseLimit(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIImageResponseLimit>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIImageResponseLimit(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnAIMaxRquired>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: MessageState.onAIMaxRequired(event.message),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_OnLocalAIInitializing>((event, emit) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onInitializingLocalAI(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
on<_ReceiveMetadata>((event, emit) {
|
||||
Log.debug("AI Steps: ${event.metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: event.metadata.sources,
|
||||
progress: event.metadata.progress,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _initializeStreamListener() {
|
||||
if (state.stream != null) {
|
||||
_startListening();
|
||||
state.stream!.listen(
|
||||
onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)),
|
||||
onError: (error) =>
|
||||
_safeAdd(ChatAIMessageEvent.receiveError(error.toString())),
|
||||
onAIResponseLimit: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onAIResponseLimit()),
|
||||
onAIImageResponseLimit: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()),
|
||||
onMetadata: (metadata) =>
|
||||
_safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)),
|
||||
onAIMaxRequired: (message) {
|
||||
Log.info(message);
|
||||
_safeAdd(ChatAIMessageEvent.onAIMaxRequired(message));
|
||||
},
|
||||
onLocalAIInitializing: () =>
|
||||
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _checkInitialStreamState() {
|
||||
if (state.stream != null) {
|
||||
if (state.stream!.aiLimitReached) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
} else if (state.stream!.error != null) {
|
||||
@@ -36,130 +151,10 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
|
||||
}
|
||||
}
|
||||
|
||||
final String chatId;
|
||||
final Int64? questionId;
|
||||
|
||||
void _dispatch() {
|
||||
on<ChatAIMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
updateText: (newText) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: newText,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveError: (error) {
|
||||
emit(state.copyWith(messageState: MessageState.onError(error)));
|
||||
},
|
||||
retry: () {
|
||||
if (questionId is! Int64) {
|
||||
Log.error("Question id is not Int64: $questionId");
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.loading(),
|
||||
),
|
||||
);
|
||||
|
||||
final payload = ChatMessageIdPB(
|
||||
chatId: chatId,
|
||||
messageId: questionId,
|
||||
);
|
||||
AIEventGetAnswerForQuestion(payload).send().then((result) {
|
||||
if (!isClosed) {
|
||||
result.fold(
|
||||
(answer) {
|
||||
add(ChatAIMessageEvent.retryResult(answer.content));
|
||||
},
|
||||
(err) {
|
||||
Log.error("Failed to get answer: $err");
|
||||
add(ChatAIMessageEvent.receiveError(err.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
retryResult: (String text) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
text: text,
|
||||
messageState: const MessageState.ready(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIResponseLimit(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: const MessageState.onAIImageResponseLimit(),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
messageState: MessageState.onAIMaxRequired(message),
|
||||
),
|
||||
);
|
||||
},
|
||||
receiveMetadata: (metadata) {
|
||||
Log.debug("AI Steps: ${metadata.progress?.step}");
|
||||
emit(
|
||||
state.copyWith(
|
||||
sources: metadata.sources,
|
||||
progress: metadata.progress,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
state.stream!.listen(
|
||||
onData: (text) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.updateText(text));
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveError(error.toString()));
|
||||
}
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIResponseLimit());
|
||||
}
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
if (!isClosed) {
|
||||
add(const ChatAIMessageEvent.onAIImageResponseLimit());
|
||||
}
|
||||
},
|
||||
onMetadata: (metadata) {
|
||||
if (!isClosed) {
|
||||
add(ChatAIMessageEvent.receiveMetadata(metadata));
|
||||
}
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
if (!isClosed) {
|
||||
Log.info(message);
|
||||
add(ChatAIMessageEvent.onAIMaxRequired(message));
|
||||
}
|
||||
},
|
||||
);
|
||||
void _safeAdd(ChatAIMessageEvent event) {
|
||||
if (!isClosed) {
|
||||
add(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +169,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
|
||||
_OnAIImageResponseLimit;
|
||||
const factory ChatAIMessageEvent.onAIMaxRequired(String message) =
|
||||
_OnAIMaxRquired;
|
||||
const factory ChatAIMessageEvent.onLocalAIInitializing() =
|
||||
_OnLocalAIInitializing;
|
||||
const factory ChatAIMessageEvent.receiveMetadata(
|
||||
MetadataCollection metadata,
|
||||
) = _ReceiveMetadata;
|
||||
@@ -209,6 +206,7 @@ class MessageState with _$MessageState {
|
||||
const factory MessageState.onAIResponseLimit() = _AIResponseLimit;
|
||||
const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit;
|
||||
const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired;
|
||||
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
|
||||
const factory MessageState.ready() = _Ready;
|
||||
const factory MessageState.loading() = _Loading;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/util/int64_extension.dart';
|
||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -28,6 +30,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
required this.userId,
|
||||
}) : chatController = InMemoryChatController(),
|
||||
listener = ChatMessageListener(chatId: chatId),
|
||||
selectedSourcesNotifier = ValueNotifier([]),
|
||||
super(ChatState.initial()) {
|
||||
_startListening();
|
||||
_dispatch();
|
||||
@@ -38,7 +41,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
final String chatId;
|
||||
final String userId;
|
||||
final ChatMessageListener listener;
|
||||
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final ChatController chatController;
|
||||
|
||||
/// The last streaming message id
|
||||
@@ -68,7 +71,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
await listener.stop();
|
||||
final request = ViewIdPB(value: chatId);
|
||||
unawaited(FolderEventCloseView(request).send());
|
||||
|
||||
selectedSourcesNotifier.dispose();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -236,9 +239,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
),
|
||||
);
|
||||
},
|
||||
regenerateAnswer: (id, format) {
|
||||
regenerateAnswer: (id, format, model) {
|
||||
_clearRelatedQuestions();
|
||||
_regenerateAnswer(id, format);
|
||||
_regenerateAnswer(id, format, model);
|
||||
lastSentMessage = null;
|
||||
|
||||
isFetchingRelatedQuestions = false;
|
||||
@@ -251,12 +254,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
},
|
||||
didReceiveChatSettings: (settings) {
|
||||
emit(
|
||||
state.copyWith(selectedSourceIds: settings.ragIds),
|
||||
);
|
||||
selectedSourcesNotifier.value = settings.ragIds;
|
||||
},
|
||||
updateSelectedSources: (selectedSourcesIds) async {
|
||||
emit(state.copyWith(selectedSourceIds: selectedSourcesIds));
|
||||
selectedSourcesNotifier.value = [...selectedSourcesIds];
|
||||
|
||||
final payload = UpdateChatSettingsPB(
|
||||
chatId: ChatId(value: chatId),
|
||||
@@ -482,6 +483,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
void _regenerateAnswer(
|
||||
String answerMessageIdString,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) async {
|
||||
final id = temporaryMessageIDMap.entries
|
||||
.firstWhereOrNull((e) => e.value == answerMessageIdString)
|
||||
@@ -504,6 +506,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
if (format != null) {
|
||||
payload.format = format.toPB();
|
||||
}
|
||||
if (model != null) {
|
||||
payload.model = model;
|
||||
}
|
||||
|
||||
await AIEventRegenerateResponse(payload).send().fold(
|
||||
(success) {
|
||||
@@ -636,6 +641,7 @@ class ChatEvent with _$ChatEvent {
|
||||
const factory ChatEvent.regenerateAnswer(
|
||||
String id,
|
||||
PredefinedFormat? format,
|
||||
AIModelPB? model,
|
||||
) = _RegenerateAnswer;
|
||||
|
||||
// streaming answer
|
||||
@@ -665,14 +671,12 @@ class ChatEvent with _$ChatEvent {
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
required List<String> selectedSourceIds,
|
||||
required LoadChatMessageStatus loadingState,
|
||||
required PromptResponseState promptResponseState,
|
||||
required bool clearErrorMessages,
|
||||
}) = _ChatState;
|
||||
|
||||
factory ChatState.initial() => const ChatState(
|
||||
selectedSourceIds: [],
|
||||
loadingState: LoadChatMessageStatus.loading,
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
clearErrorMessages: false,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
@@ -140,87 +137,3 @@ enum LoadChatMessageStatus {
|
||||
loadingRemote,
|
||||
ready,
|
||||
}
|
||||
|
||||
class PredefinedFormat extends Equatable {
|
||||
const PredefinedFormat({
|
||||
required this.imageFormat,
|
||||
required this.textFormat,
|
||||
});
|
||||
|
||||
const PredefinedFormat.auto()
|
||||
: imageFormat = ImageFormat.text,
|
||||
textFormat = TextFormat.paragraph;
|
||||
|
||||
final ImageFormat imageFormat;
|
||||
final TextFormat? textFormat;
|
||||
|
||||
PredefinedFormatPB toPB() {
|
||||
return PredefinedFormatPB(
|
||||
imageFormat: switch (imageFormat) {
|
||||
ImageFormat.text => ResponseImageFormatPB.TextOnly,
|
||||
ImageFormat.image => ResponseImageFormatPB.ImageOnly,
|
||||
ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
|
||||
},
|
||||
textFormat: switch (textFormat) {
|
||||
TextFormat.paragraph => ResponseTextFormatPB.Paragraph,
|
||||
TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
|
||||
TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
|
||||
TextFormat.table => ResponseTextFormatPB.Table,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [imageFormat, textFormat];
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
text,
|
||||
image,
|
||||
textAndImage;
|
||||
|
||||
bool get hasText => this == text || this == textAndImage;
|
||||
|
||||
FlowySvgData get icon {
|
||||
return switch (this) {
|
||||
ImageFormat.text => FlowySvgs.ai_text_s,
|
||||
ImageFormat.image => FlowySvgs.ai_image_s,
|
||||
ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
|
||||
};
|
||||
}
|
||||
|
||||
String get i18n {
|
||||
return switch (this) {
|
||||
ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
|
||||
ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
|
||||
ImageFormat.textAndImage =>
|
||||
LocaleKeys.chat_changeFormat_textAndImage.tr(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum TextFormat {
|
||||
paragraph,
|
||||
bulletList,
|
||||
numberedList,
|
||||
table;
|
||||
|
||||
FlowySvgData get icon {
|
||||
return switch (this) {
|
||||
TextFormat.paragraph => FlowySvgs.ai_paragraph_s,
|
||||
TextFormat.bulletList => FlowySvgs.ai_list_s,
|
||||
TextFormat.numberedList => FlowySvgs.ai_number_list_s,
|
||||
TextFormat.table => FlowySvgs.ai_table_s,
|
||||
};
|
||||
}
|
||||
|
||||
String get i18n {
|
||||
return switch (this) {
|
||||
TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(),
|
||||
TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
|
||||
TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
|
||||
TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
|
||||
};
|
||||
}
|
||||
}
|
||||
+70
-41
@@ -2,55 +2,25 @@ import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:appflowy/ai/service/ai_entities.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
|
||||
|
||||
/// A stream that receives answer events from an isolate or external process.
|
||||
/// It caches events that might occur before a listener is attached.
|
||||
class AnswerStream {
|
||||
AnswerStream() {
|
||||
_port.handler = _controller.add;
|
||||
_subscription = _controller.stream.listen(
|
||||
(event) {
|
||||
if (event.startsWith("data:")) {
|
||||
_hasStarted = true;
|
||||
final newText = event.substring(5);
|
||||
_text += newText;
|
||||
_onData?.call(_text);
|
||||
} else if (event.startsWith("error:")) {
|
||||
_error = event.substring(5);
|
||||
_onError?.call(_error!);
|
||||
} else if (event.startsWith("metadata:")) {
|
||||
if (_onMetadata != null) {
|
||||
final s = event.substring(9);
|
||||
_onMetadata!(parseMetadata(s));
|
||||
}
|
||||
} else if (event == "AI_RESPONSE_LIMIT") {
|
||||
_aiLimitReached = true;
|
||||
_onAIResponseLimit?.call();
|
||||
} else if (event == "AI_IMAGE_RESPONSE_LIMIT") {
|
||||
_aiImageLimitReached = true;
|
||||
_onAIImageResponseLimit?.call();
|
||||
} else if (event.startsWith("AI_MAX_REQUIRED:")) {
|
||||
final msg = event.substring(16);
|
||||
// If the callback is not registered yet, add the event to the buffer.
|
||||
if (_onAIMaxRequired != null) {
|
||||
_onAIMaxRequired!(msg);
|
||||
} else {
|
||||
_pendingAIMaxRequiredEvents.add(msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
_onEnd?.call();
|
||||
},
|
||||
onError: (error) {
|
||||
_error = error.toString();
|
||||
_onError?.call(error.toString());
|
||||
},
|
||||
_handleEvent,
|
||||
onDone: _onDoneCallback,
|
||||
onError: _handleError,
|
||||
);
|
||||
}
|
||||
|
||||
final RawReceivePort _port = RawReceivePort();
|
||||
final StreamController<String> _controller = StreamController.broadcast();
|
||||
late StreamSubscription<String> _subscription;
|
||||
|
||||
bool _hasStarted = false;
|
||||
bool _aiLimitReached = false;
|
||||
bool _aiImageLimitReached = false;
|
||||
@@ -62,13 +32,15 @@ class AnswerStream {
|
||||
void Function()? _onStart;
|
||||
void Function()? _onEnd;
|
||||
void Function(String error)? _onError;
|
||||
void Function()? _onLocalAIInitializing;
|
||||
void Function()? _onAIResponseLimit;
|
||||
void Function()? _onAIImageResponseLimit;
|
||||
void Function(String message)? _onAIMaxRequired;
|
||||
void Function(MetadataCollection metadataCollection)? _onMetadata;
|
||||
void Function(MetadataCollection metadata)? _onMetadata;
|
||||
|
||||
// Buffer for events that occur before listen() is called.
|
||||
// Caches for events that occur before listen() is called.
|
||||
final List<String> _pendingAIMaxRequiredEvents = [];
|
||||
bool _pendingLocalAINotReady = false;
|
||||
|
||||
int get nativePort => _port.sendPort.nativePort;
|
||||
bool get hasStarted => _hasStarted;
|
||||
@@ -77,12 +49,61 @@ class AnswerStream {
|
||||
String? get error => _error;
|
||||
String get text => _text;
|
||||
|
||||
/// Releases the resources used by the AnswerStream.
|
||||
Future<void> dispose() async {
|
||||
await _controller.close();
|
||||
await _subscription.cancel();
|
||||
_port.close();
|
||||
}
|
||||
|
||||
/// Handles incoming events from the underlying stream.
|
||||
void _handleEvent(String event) {
|
||||
if (event.startsWith(AIStreamEventPrefix.data)) {
|
||||
_hasStarted = true;
|
||||
final newText = event.substring(AIStreamEventPrefix.data.length);
|
||||
_text += newText;
|
||||
_onData?.call(_text);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.error)) {
|
||||
_error = event.substring(AIStreamEventPrefix.error.length);
|
||||
_onError?.call(_error!);
|
||||
} else if (event.startsWith(AIStreamEventPrefix.metadata)) {
|
||||
final s = event.substring(AIStreamEventPrefix.metadata.length);
|
||||
_onMetadata?.call(parseMetadata(s));
|
||||
} else if (event == AIStreamEventPrefix.aiResponseLimit) {
|
||||
_aiLimitReached = true;
|
||||
_onAIResponseLimit?.call();
|
||||
} else if (event == AIStreamEventPrefix.aiImageResponseLimit) {
|
||||
_aiImageLimitReached = true;
|
||||
_onAIImageResponseLimit?.call();
|
||||
} else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
|
||||
final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length);
|
||||
if (_onAIMaxRequired != null) {
|
||||
_onAIMaxRequired!(msg);
|
||||
} else {
|
||||
_pendingAIMaxRequiredEvents.add(msg);
|
||||
}
|
||||
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
|
||||
if (_onLocalAIInitializing != null) {
|
||||
_onLocalAIInitializing!();
|
||||
} else {
|
||||
_pendingLocalAINotReady = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onDoneCallback() {
|
||||
_onEnd?.call();
|
||||
}
|
||||
|
||||
void _handleError(dynamic error) {
|
||||
_error = error.toString();
|
||||
_onError?.call(_error!);
|
||||
}
|
||||
|
||||
/// Registers listeners for various events.
|
||||
///
|
||||
/// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY),
|
||||
/// they will be flushed immediately.
|
||||
void listen({
|
||||
void Function(String text)? onData,
|
||||
void Function()? onStart,
|
||||
@@ -92,6 +113,7 @@ class AnswerStream {
|
||||
void Function()? onAIImageResponseLimit,
|
||||
void Function(String message)? onAIMaxRequired,
|
||||
void Function(MetadataCollection metadata)? onMetadata,
|
||||
void Function()? onLocalAIInitializing,
|
||||
}) {
|
||||
_onData = onData;
|
||||
_onStart = onStart;
|
||||
@@ -99,10 +121,11 @@ class AnswerStream {
|
||||
_onError = onError;
|
||||
_onAIResponseLimit = onAIResponseLimit;
|
||||
_onAIImageResponseLimit = onAIImageResponseLimit;
|
||||
_onMetadata = onMetadata;
|
||||
_onAIMaxRequired = onAIMaxRequired;
|
||||
_onMetadata = onMetadata;
|
||||
_onLocalAIInitializing = onLocalAIInitializing;
|
||||
|
||||
// Flush any buffered AI_MAX_REQUIRED events.
|
||||
// Flush pending AI_MAX_REQUIRED events.
|
||||
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
|
||||
for (final msg in _pendingAIMaxRequiredEvents) {
|
||||
_onAIMaxRequired!(msg);
|
||||
@@ -110,6 +133,12 @@ class AnswerStream {
|
||||
_pendingAIMaxRequiredEvents.clear();
|
||||
}
|
||||
|
||||
// Flush pending LOCAL_AI_NOT_READY event.
|
||||
if (_pendingLocalAINotReady && _onLocalAIInitializing != null) {
|
||||
_onLocalAIInitializing!();
|
||||
_pendingLocalAINotReady = false;
|
||||
}
|
||||
|
||||
_onStart?.call();
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -19,9 +19,17 @@ class ChatSelectMessageBloc
|
||||
on<ChatSelectMessageEvent>(
|
||||
(event, emit) {
|
||||
event.when(
|
||||
enableStartSelectingMessages: () {
|
||||
emit(state.copyWith(enabled: true));
|
||||
},
|
||||
toggleSelectingMessages: () {
|
||||
if (state.isSelectingMessages) {
|
||||
emit(ChatSelectMessageState.initial());
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSelectingMessages: false,
|
||||
selectedMessages: [],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(state.copyWith(isSelectingMessages: true));
|
||||
}
|
||||
@@ -50,8 +58,13 @@ class ChatSelectMessageBloc
|
||||
unselectAllMessages: () {
|
||||
emit(state.copyWith(selectedMessages: const []));
|
||||
},
|
||||
saveAsPage: () {
|
||||
emit(ChatSelectMessageState.initial());
|
||||
reset: () {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSelectingMessages: false,
|
||||
selectedMessages: [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -70,6 +83,8 @@ class ChatSelectMessageBloc
|
||||
|
||||
@freezed
|
||||
class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
|
||||
const factory ChatSelectMessageEvent.enableStartSelectingMessages() =
|
||||
_EnableStartSelectingMessages;
|
||||
const factory ChatSelectMessageEvent.toggleSelectingMessages() =
|
||||
_ToggleSelectingMessages;
|
||||
const factory ChatSelectMessageEvent.toggleSelectMessage(Message message) =
|
||||
@@ -79,7 +94,7 @@ class ChatSelectMessageEvent with _$ChatSelectMessageEvent {
|
||||
) = _SelectAllMessages;
|
||||
const factory ChatSelectMessageEvent.unselectAllMessages() =
|
||||
_UnselectAllMessages;
|
||||
const factory ChatSelectMessageEvent.saveAsPage() = _SaveAsPage;
|
||||
const factory ChatSelectMessageEvent.reset() = _Reset;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -87,9 +102,11 @@ class ChatSelectMessageState with _$ChatSelectMessageState {
|
||||
const factory ChatSelectMessageState({
|
||||
required bool isSelectingMessages,
|
||||
required List<Message> selectedMessages,
|
||||
required bool enabled,
|
||||
}) = _ChatSelectMessageState;
|
||||
|
||||
factory ChatSelectMessageState.initial() => const ChatSelectMessageState(
|
||||
enabled: false,
|
||||
isSelectingMessages: false,
|
||||
selectedMessages: [],
|
||||
);
|
||||
|
||||
+2
-3
@@ -113,9 +113,8 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
|
||||
String filter = '';
|
||||
|
||||
void updateSelectedSources(List<String> newSelectedSourceIds) {
|
||||
selectedSourceIds
|
||||
..clear
|
||||
..addAll(newSelectedSourceIds);
|
||||
selectedSourceIds.clear();
|
||||
selectedSourceIds.addAll(newSelectedSourceIds);
|
||||
}
|
||||
|
||||
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
|
||||
|
||||
@@ -178,8 +178,12 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
|
||||
customActions: [
|
||||
CustomViewAction(
|
||||
view: notifier.view,
|
||||
disabled: !state.enabled,
|
||||
leftIcon: FlowySvgs.ai_add_to_page_s,
|
||||
label: LocaleKeys.moreAction_saveAsNewPage.tr(),
|
||||
tooltipMessage: state.enabled
|
||||
? null
|
||||
: LocaleKeys.moreAction_saveAsNewPageDisabled.tr(),
|
||||
onTap: () {
|
||||
chatMessageSelectorBloc.add(
|
||||
const ChatSelectMessageEvent
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||
@@ -19,14 +21,12 @@ import 'package:string_validator/string_validator.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'application/ai_prompt_input_bloc.dart';
|
||||
import 'application/chat_bloc.dart';
|
||||
import 'application/chat_entity.dart';
|
||||
import 'application/chat_member_bloc.dart';
|
||||
import 'application/chat_select_message_bloc.dart';
|
||||
import 'application/chat_message_stream.dart';
|
||||
import 'presentation/animated_chat_list.dart';
|
||||
import 'presentation/chat_input/desktop_chat_input.dart';
|
||||
import 'presentation/chat_input/mobile_chat_input.dart';
|
||||
import 'presentation/chat_related_question.dart';
|
||||
import 'presentation/chat_welcome_page.dart';
|
||||
@@ -71,7 +71,15 @@ class AIChatPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
/// [AIPromptInputBloc] is used to handle the user prompt
|
||||
BlocProvider(create: (_) => AIPromptInputBloc()),
|
||||
BlocProvider(
|
||||
create: (_) => AIPromptInputBloc(
|
||||
objectId: view.id,
|
||||
predefinedFormat: PredefinedFormat(
|
||||
imageFormat: ImageFormat.text,
|
||||
textFormat: TextFormat.bulletList,
|
||||
),
|
||||
),
|
||||
),
|
||||
BlocProvider(create: (_) => ChatMemberBloc()),
|
||||
],
|
||||
child: Builder(
|
||||
@@ -86,9 +94,29 @@ class AIChatPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
child: FocusScope(
|
||||
onKeyEvent: (focusNode, event) {
|
||||
if (event is! KeyUpEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape ||
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
HardwareKeyboard.instance.isControlPressed) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
if (chatBloc.state.promptResponseState !=
|
||||
PromptResponseState.ready) {
|
||||
chatBloc.add(ChatEvent.stopStream());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: _ChatContentPage(
|
||||
view: view,
|
||||
userProfile: userProfile,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -146,7 +174,7 @@ class _ChatContentPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
_wrapConstraints(
|
||||
_builtInput(context),
|
||||
_Input(view: view),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -184,26 +212,25 @@ class _ChatContentPage extends StatelessWidget {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.sendMessage(message: question));
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (message.author.id == userProfile.id.toString()) {
|
||||
if (message.author.id == userProfile.id.toString() ||
|
||||
isOtherUserMessage(message)) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
isCurrentUser: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (isOtherUserMessage(message)) {
|
||||
return ChatUserMessageWidget(
|
||||
user: message.author,
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,10 +265,16 @@ class _ChatContentPage extends StatelessWidget {
|
||||
_onSelectMetadata(context, metadata),
|
||||
onRegenerate: () => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null)),
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
|
||||
onChangeFormat: (format) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, format)),
|
||||
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
|
||||
onChangeModel: (model) => context
|
||||
.read<ChatBloc>()
|
||||
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
|
||||
onStopStream: () => context.read<ChatBloc>().add(
|
||||
const ChatEvent.stopStream(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -286,11 +319,24 @@ class _ChatContentPage extends StatelessWidget {
|
||||
return ChatWelcomePage(
|
||||
userProfile: userProfile,
|
||||
onSelectedQuestion: (question) {
|
||||
bloc.add(ChatEvent.sendMessage(message: question));
|
||||
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats =
|
||||
aiPromptInputBloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
|
||||
bloc.add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
context
|
||||
.read<ChatSelectMessageBloc>()
|
||||
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
|
||||
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
@@ -308,7 +354,62 @@ class _ChatContentPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _builtInput(BuildContext context) {
|
||||
void _onSelectMetadata(
|
||||
BuildContext context,
|
||||
ChatMessageRefSource metadata,
|
||||
) async {
|
||||
// When the source of metatdata is appflowy, which means it is a appflowy page
|
||||
if (metadata.source == "appflowy") {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, sidebarView);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.source == "web") {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Input extends StatefulWidget {
|
||||
const _Input({
|
||||
required this.view,
|
||||
});
|
||||
|
||||
final ViewPB view;
|
||||
|
||||
@override
|
||||
State<_Input> createState() => _InputState();
|
||||
}
|
||||
|
||||
class _InputState extends State<_Input> {
|
||||
final textController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
|
||||
selector: (state) => state.isSelectingMessages,
|
||||
builder: (context, isSelectingMessages) {
|
||||
@@ -334,9 +435,9 @@ class _ChatContentPage extends StatelessWidget {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
|
||||
return UniversalPlatform.isDesktop
|
||||
? DesktopChatInput(
|
||||
chatId: view.id,
|
||||
? DesktopPromptInput(
|
||||
isStreaming: !canSendMessage,
|
||||
textController: textController,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
},
|
||||
@@ -349,6 +450,8 @@ class _ChatContentPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedSourcesNotifier:
|
||||
chatBloc.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
@@ -358,7 +461,6 @@ class _ChatContentPage extends StatelessWidget {
|
||||
},
|
||||
)
|
||||
: MobileChatInput(
|
||||
chatId: view.id,
|
||||
isStreaming: !canSendMessage,
|
||||
onStopStreaming: () {
|
||||
chatBloc.add(const ChatEvent.stopStream());
|
||||
@@ -372,6 +474,8 @@ class _ChatContentPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
selectedSourcesNotifier:
|
||||
chatBloc.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: (ids) {
|
||||
chatBloc.add(
|
||||
ChatEvent.updateSelectedSources(
|
||||
@@ -387,30 +491,4 @@ class _ChatContentPage extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onSelectMetadata(
|
||||
BuildContext context,
|
||||
ChatMessageRefSource metadata,
|
||||
) async {
|
||||
if (isURL(metadata.name)) {
|
||||
late Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(metadata.name);
|
||||
// `Uri` identifies `localhost` as a scheme
|
||||
if (!uri.hasScheme || uri.scheme == 'localhost') {
|
||||
uri = Uri.parse("http://${metadata.name}");
|
||||
await InternetAddress.lookup(uri.host);
|
||||
}
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
Log.error("failed to open url $err");
|
||||
}
|
||||
} else {
|
||||
final sidebarView =
|
||||
await ViewBackendService.getView(metadata.id).toNullable();
|
||||
if (context.mounted) {
|
||||
openPageFromMessage(context, sidebarView);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-71
@@ -1,8 +1,7 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:extended_text_field/extended_text_field.dart';
|
||||
@@ -10,21 +9,19 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../layout_define.dart';
|
||||
|
||||
class MobileChatInput extends StatefulWidget {
|
||||
const MobileChatInput({
|
||||
super.key,
|
||||
required this.chatId,
|
||||
required this.isStreaming,
|
||||
required this.onStopStreaming,
|
||||
required this.onSubmitted,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final String chatId;
|
||||
final bool isStreaming;
|
||||
final void Function() onStopStreaming;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
|
||||
onSubmitted;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
@@ -38,18 +35,13 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
final focusNode = FocusNode();
|
||||
final textController = TextEditingController();
|
||||
|
||||
bool showPredefinedFormatSection = true;
|
||||
PredefinedFormat predefinedFormat = const PredefinedFormat(
|
||||
imageFormat: ImageFormat.text,
|
||||
textFormat: TextFormat.bulletList,
|
||||
);
|
||||
late SendButtonState sendButtonState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
textController.addListener(handleTextControllerChange);
|
||||
textController.addListener(handleTextControllerChanged);
|
||||
// focusNode.onKeyEvent = handleKeyEvent;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -106,53 +98,62 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(8.0)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MobileAIPromptSizes.attachedFilesBarPadding.vertical +
|
||||
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MobileAIPromptSizes
|
||||
.attachedFilesBarPadding.vertical +
|
||||
MobileAIPromptSizes.attachedFilesPreviewHeight,
|
||||
),
|
||||
child: PromptInputFile(
|
||||
chatId: widget.chatId,
|
||||
onDeleted: (file) => context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.removeFile(file)),
|
||||
),
|
||||
),
|
||||
if (showPredefinedFormatSection)
|
||||
TextFieldTapRegion(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ChangeFormatBar(
|
||||
predefinedFormat: predefinedFormat,
|
||||
spacing: 8.0,
|
||||
onSelectPredefinedFormat: (format) {
|
||||
setState(() => predefinedFormat = format);
|
||||
},
|
||||
),
|
||||
child: PromptInputFile(
|
||||
onDeleted: (file) => context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.removeFile(file)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const VSpace(8.0),
|
||||
inputTextField(context),
|
||||
TextFieldTapRegion(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8.0),
|
||||
leadingButtons(context),
|
||||
const Spacer(),
|
||||
sendButton(),
|
||||
const HSpace(12.0),
|
||||
],
|
||||
if (state.showPredefinedFormats)
|
||||
TextFieldTapRegion(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: ChangeFormatBar(
|
||||
predefinedFormat: state.predefinedFormat,
|
||||
spacing: 8.0,
|
||||
onSelectPredefinedFormat: (format) =>
|
||||
context.read<AIPromptInputBloc>().add(
|
||||
AIPromptInputEvent.updatePredefinedFormat(
|
||||
format,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const VSpace(8.0),
|
||||
inputTextField(context),
|
||||
TextFieldTapRegion(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const HSpace(8.0),
|
||||
leadingButtons(
|
||||
context,
|
||||
state.showPredefinedFormats,
|
||||
),
|
||||
const Spacer(),
|
||||
sendButton(),
|
||||
const HSpace(12.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -185,14 +186,18 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
// get the attached files and mentioned pages
|
||||
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
|
||||
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
|
||||
widget.onSubmitted(
|
||||
trimmedText,
|
||||
showPredefinedFormatSection ? predefinedFormat : null,
|
||||
showPredefinedFormats ? predefinedFormat : null,
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
void handleTextControllerChange() {
|
||||
void handleTextControllerChanged() {
|
||||
if (textController.value.isComposingRangeValid) {
|
||||
return;
|
||||
}
|
||||
@@ -263,10 +268,10 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: MobileAIPromptSizes.textFieldContentPadding,
|
||||
hintText: switch (state.aiType) {
|
||||
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
AiType.cloud => LocaleKeys.chat_inputMessageHint.tr(),
|
||||
AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr()
|
||||
},
|
||||
hintStyle: AIChatUILayout.inputHintTextStyle(context),
|
||||
hintStyle: inputHintTextStyle(context),
|
||||
isCollapsed: true,
|
||||
isDense: true,
|
||||
),
|
||||
@@ -289,7 +294,15 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget leadingButtons(BuildContext context) {
|
||||
TextStyle? inputHintTextStyle(BuildContext context) {
|
||||
return Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0xFFBDC2C8)
|
||||
: const Color(0xFF3C3E51),
|
||||
);
|
||||
}
|
||||
|
||||
Widget leadingButtons(BuildContext context, bool showPredefinedFormats) {
|
||||
return _LeadingActions(
|
||||
// onMention: () {
|
||||
// textController.text += '@';
|
||||
@@ -300,13 +313,13 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
// mentionPage(context);
|
||||
// });
|
||||
// },
|
||||
showPredefinedFormatSection: showPredefinedFormatSection,
|
||||
predefinedFormat: predefinedFormat,
|
||||
showPredefinedFormats: showPredefinedFormats,
|
||||
onTogglePredefinedFormatSection: () {
|
||||
setState(() {
|
||||
showPredefinedFormatSection = !showPredefinedFormatSection;
|
||||
});
|
||||
context
|
||||
.read<AIPromptInputBloc>()
|
||||
.add(AIPromptInputEvent.toggleShowPredefinedFormat());
|
||||
},
|
||||
selectedSourcesNotifier: widget.selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: widget.onUpdateSelectedSources,
|
||||
);
|
||||
}
|
||||
@@ -322,15 +335,15 @@ class _MobileChatInputState extends State<MobileChatInput> {
|
||||
|
||||
class _LeadingActions extends StatelessWidget {
|
||||
const _LeadingActions({
|
||||
required this.showPredefinedFormatSection,
|
||||
required this.predefinedFormat,
|
||||
required this.showPredefinedFormats,
|
||||
required this.onTogglePredefinedFormatSection,
|
||||
required this.selectedSourcesNotifier,
|
||||
required this.onUpdateSelectedSources,
|
||||
});
|
||||
|
||||
final bool showPredefinedFormatSection;
|
||||
final PredefinedFormat predefinedFormat;
|
||||
final bool showPredefinedFormats;
|
||||
final void Function() onTogglePredefinedFormatSection;
|
||||
final ValueNotifier<List<String>> selectedSourcesNotifier;
|
||||
final void Function(List<String>) onUpdateSelectedSources;
|
||||
|
||||
@override
|
||||
@@ -342,10 +355,11 @@ class _LeadingActions extends StatelessWidget {
|
||||
separatorBuilder: () => const HSpace(4.0),
|
||||
children: [
|
||||
PromptInputMobileSelectSourcesButton(
|
||||
selectedSourcesNotifier: selectedSourcesNotifier,
|
||||
onUpdateSelectedSources: onUpdateSelectedSources,
|
||||
),
|
||||
PromptInputMobileToggleFormatButton(
|
||||
showFormatBar: showPredefinedFormatSection,
|
||||
showFormatBar: showPredefinedFormats,
|
||||
onTap: onTogglePredefinedFormatSection,
|
||||
),
|
||||
],
|
||||
|
||||
+2
-2
@@ -250,7 +250,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
showSaveMessageSuccessToast(context, view);
|
||||
}
|
||||
|
||||
bloc.add(const ChatSelectMessageEvent.saveAsPage());
|
||||
bloc.add(const ChatSelectMessageEvent.reset());
|
||||
|
||||
return view;
|
||||
}
|
||||
@@ -275,7 +275,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
|
||||
showSaveMessageSuccessToast(context, newView);
|
||||
openPageFromMessage(context, newView);
|
||||
}
|
||||
bloc.add(const ChatSelectMessageEvent.saveAsPage());
|
||||
bloc.add(const ChatSelectMessageEvent.reset());
|
||||
}
|
||||
|
||||
Future<void> forceReload(String documentId) async {
|
||||
|
||||
+30
-27
@@ -21,33 +21,35 @@ class RelatedQuestionList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: relatedQuestions.length + 1,
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
|
||||
separatorBuilder: (context, index) => const VSpace(4.0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_relatedQuestion.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: RelatedQuestionItem(
|
||||
question: relatedQuestions[index - 1],
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
return SelectionContainer.disabled(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: relatedQuestions.length + 1,
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
|
||||
separatorBuilder: (context, index) => const VSpace(4.0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_relatedQuestion.tr(),
|
||||
color: Theme.of(context).hintColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: RelatedQuestionItem(
|
||||
question: relatedQuestions[index - 1],
|
||||
onQuestionSelected: onQuestionSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +72,7 @@ class RelatedQuestionItem extends StatelessWidget {
|
||||
child: FlowyText(
|
||||
question,
|
||||
lineHeight: 1.4,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:appflowy/util/theme_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
@@ -20,14 +19,6 @@ class AIChatUILayout {
|
||||
static EdgeInsets get messageMargin => UniversalPlatform.isMobile
|
||||
? const EdgeInsets.symmetric(horizontal: 16)
|
||||
: EdgeInsets.zero;
|
||||
|
||||
static TextStyle? inputHintTextStyle(BuildContext context) {
|
||||
return Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).isLightMode
|
||||
? const Color(0xFFBDC2C8)
|
||||
: const Color(0xFF3C3E51),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopAIChatSizes {
|
||||
|
||||
+1
-1
@@ -1,9 +1,9 @@
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<AIModelPB?> showChangeModelBottomSheet(
|
||||
BuildContext context,
|
||||
List<AIModelPB> models,
|
||||
) {
|
||||
return showMobileBottomSheet<AIModelPB?>(
|
||||
context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _ChangeModelBottomSheetContent(models: models),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChangeModelBottomSheetContent extends StatefulWidget {
|
||||
const _ChangeModelBottomSheetContent({
|
||||
required this.models,
|
||||
});
|
||||
|
||||
final List<AIModelPB> models;
|
||||
|
||||
@override
|
||||
State<_ChangeModelBottomSheetContent> createState() =>
|
||||
_ChangeModelBottomSheetContentState();
|
||||
}
|
||||
|
||||
class _ChangeModelBottomSheetContentState
|
||||
extends State<_ChangeModelBottomSheetContent> {
|
||||
AIModelPB? model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Header(
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onDone: () => Navigator.of(context).pop(model),
|
||||
),
|
||||
const VSpace(4.0),
|
||||
_Body(
|
||||
models: widget.models,
|
||||
selectedModel: model,
|
||||
onSelectModel: (format) {
|
||||
setState(() => model = format);
|
||||
},
|
||||
),
|
||||
const VSpace(16.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.onCancel,
|
||||
required this.onDone,
|
||||
});
|
||||
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44.0,
|
||||
child: Stack(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AppBarBackButton(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
onTap: onCancel,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: FlowyText(
|
||||
LocaleKeys.chat_switchModel_label.tr(),
|
||||
fontSize: 17.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: AppBarDoneButton(
|
||||
onTap: onDone,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onSelectModel,
|
||||
});
|
||||
|
||||
final List<AIModelPB> models;
|
||||
final AIModelPB? selectedModel;
|
||||
final void Function(AIModelPB) onSelectModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: models
|
||||
.mapIndexed(
|
||||
(index, model) => _buildModelButton(model, index == 0),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModelButton(
|
||||
AIModelPB model, [
|
||||
bool isFirst = false,
|
||||
]) {
|
||||
return FlowyOptionTile.checkbox(
|
||||
text: model.name,
|
||||
isSelected: model == selectedModel,
|
||||
showTopBorder: isFirst,
|
||||
onTap: () {
|
||||
onSelectModel(model);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
-3
@@ -64,8 +64,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.markdown != widget.markdown) {
|
||||
editorState.dispose();
|
||||
editorState = _parseMarkdown(widget.markdown.trim());
|
||||
final editorState = _parseMarkdown(
|
||||
widget.markdown.trim(),
|
||||
previousDocument: this.editorState.document,
|
||||
);
|
||||
this.editorState.dispose();
|
||||
this.editorState = editorState;
|
||||
scrollController.dispose();
|
||||
scrollController = EditorScrollController(
|
||||
editorState: editorState,
|
||||
@@ -129,8 +133,30 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
|
||||
);
|
||||
}
|
||||
|
||||
EditorState _parseMarkdown(String markdown) {
|
||||
EditorState _parseMarkdown(
|
||||
String markdown, {
|
||||
Document? previousDocument,
|
||||
}) {
|
||||
// merge the nodes from the previous document with the new document to keep the same node ids
|
||||
final document = customMarkdownToDocument(markdown);
|
||||
final documentIterator = NodeIterator(
|
||||
document: document,
|
||||
startNode: document.root,
|
||||
);
|
||||
if (previousDocument != null) {
|
||||
final previousDocumentIterator = NodeIterator(
|
||||
document: previousDocument,
|
||||
startNode: previousDocument.root,
|
||||
);
|
||||
while (
|
||||
documentIterator.moveNext() && previousDocumentIterator.moveNext()) {
|
||||
final currentNode = documentIterator.current;
|
||||
final previousNode = previousDocumentIterator.current;
|
||||
if (currentNode.path.equals(previousNode.path)) {
|
||||
currentNode.id = previousNode.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
final editorState = EditorState(document: document);
|
||||
return editorState;
|
||||
}
|
||||
|
||||
+106
-14
@@ -1,14 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/ai/widgets/prompt_input/layout_define.dart';
|
||||
import 'package:appflowy/ai/widgets/prompt_input/predefined_format_buttons.dart';
|
||||
import 'package:appflowy/ai/widgets/prompt_input/select_sources_menu.dart';
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
|
||||
import 'package:appflowy/plugins/document/application/prelude.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
@@ -24,6 +21,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
@@ -44,6 +42,7 @@ class AIMessageActionBar extends StatefulWidget {
|
||||
required this.showDecoration,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
this.onOverrideVisibility,
|
||||
});
|
||||
|
||||
@@ -51,6 +50,7 @@ class AIMessageActionBar extends StatefulWidget {
|
||||
final bool showDecoration;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
final void Function(bool)? onOverrideVisibility;
|
||||
|
||||
@override
|
||||
@@ -129,6 +129,12 @@ class _AIMessageActionBarState extends State<AIMessageActionBar> {
|
||||
popoverMutex: popoverMutex,
|
||||
onOverrideVisibility: widget.onOverrideVisibility,
|
||||
),
|
||||
ChangeModelButton(
|
||||
isInHoverBar: widget.showDecoration,
|
||||
onRegenerate: widget.onChangeModel,
|
||||
popoverMutex: popoverMutex,
|
||||
onOverrideVisibility: widget.onOverrideVisibility,
|
||||
),
|
||||
SaveToPageButton(
|
||||
textMessage: widget.message as TextMessage,
|
||||
isInHoverBar: widget.showDecoration,
|
||||
@@ -178,8 +184,7 @@ class CopyButton extends StatelessWidget {
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
message: LocaleKeys.message_copy_success.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -220,7 +225,7 @@ class RegenerateButton extends StatelessWidget {
|
||||
? DesktopAIChatSizes.messageHoverActionBarIconRadius
|
||||
: DesktopAIChatSizes.messageActionBarIconRadius,
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.ai_undo_s,
|
||||
FlowySvgs.ai_try_again_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
@@ -263,8 +268,11 @@ class _ChangeFormatButtonState extends State<ChangeFormatButton> {
|
||||
constraints: const BoxConstraints(),
|
||||
onClose: () => widget.onOverrideVisibility?.call(false),
|
||||
child: buildButton(context),
|
||||
popupBuilder: (_) => _ChangeFormatPopoverContent(
|
||||
onRegenerate: widget.onRegenerate,
|
||||
popupBuilder: (_) => BlocProvider.value(
|
||||
value: context.read<AIPromptInputBloc>(),
|
||||
child: _ChangeFormatPopoverContent(
|
||||
onRegenerate: widget.onRegenerate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -362,11 +370,16 @@ class _ChangeFormatPopoverContentState
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ChangeFormatBar(
|
||||
spacing: 2.0,
|
||||
predefinedFormat: predefinedFormat,
|
||||
onSelectPredefinedFormat: (format) {
|
||||
setState(() => predefinedFormat = format);
|
||||
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
|
||||
builder: (context, state) {
|
||||
return ChangeFormatBar(
|
||||
spacing: 2.0,
|
||||
showImageFormats: state.aiType.isCloud,
|
||||
predefinedFormat: predefinedFormat,
|
||||
onSelectPredefinedFormat: (format) {
|
||||
setState(() => predefinedFormat = format);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const HSpace(4.0),
|
||||
@@ -400,6 +413,85 @@ class _ChangeFormatPopoverContentState
|
||||
}
|
||||
}
|
||||
|
||||
class ChangeModelButton extends StatefulWidget {
|
||||
const ChangeModelButton({
|
||||
super.key,
|
||||
required this.isInHoverBar,
|
||||
this.popoverMutex,
|
||||
this.onRegenerate,
|
||||
this.onOverrideVisibility,
|
||||
});
|
||||
|
||||
final bool isInHoverBar;
|
||||
final PopoverMutex? popoverMutex;
|
||||
final void Function(AIModelPB)? onRegenerate;
|
||||
final void Function(bool)? onOverrideVisibility;
|
||||
|
||||
@override
|
||||
State<ChangeModelButton> createState() => _ChangeModelButtonState();
|
||||
}
|
||||
|
||||
class _ChangeModelButtonState extends State<ChangeModelButton> {
|
||||
final popoverController = PopoverController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppFlowyPopover(
|
||||
controller: popoverController,
|
||||
mutex: widget.popoverMutex,
|
||||
triggerActions: PopoverTriggerFlags.none,
|
||||
margin: EdgeInsets.zero,
|
||||
offset: Offset(8, 0),
|
||||
direction: PopoverDirection.rightWithBottomAligned,
|
||||
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
|
||||
onClose: () => widget.onOverrideVisibility?.call(false),
|
||||
child: buildButton(context),
|
||||
popupBuilder: (_) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
|
||||
return SelectModelPopoverContent(
|
||||
models: models,
|
||||
selectedModel: null,
|
||||
onSelectModel: widget.onRegenerate,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildButton(BuildContext context) {
|
||||
return FlowyTooltip(
|
||||
message: LocaleKeys.chat_switchModel_label.tr(),
|
||||
child: FlowyIconButton(
|
||||
width: 32.0,
|
||||
height: DesktopAIChatSizes.messageActionBarIconSize,
|
||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||
radius: widget.isInHoverBar
|
||||
? DesktopAIChatSizes.messageHoverActionBarIconRadius
|
||||
: DesktopAIChatSizes.messageActionBarIconRadius,
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_sparks_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(16),
|
||||
),
|
||||
FlowySvg(
|
||||
FlowySvgs.ai_source_drop_down_s,
|
||||
color: Theme.of(context).hintColor,
|
||||
size: const Size.square(8),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
widget.onOverrideVisibility?.call(true);
|
||||
popoverController.show();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SaveToPageButton extends StatefulWidget {
|
||||
const SaveToPageButton({
|
||||
super.key,
|
||||
|
||||
+57
-21
@@ -1,17 +1,18 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
|
||||
import 'package:appflowy/shared/markdown_to_document.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -22,8 +23,8 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
import '../chat_avatar.dart';
|
||||
import '../../../../ai/widgets/prompt_input/mention_page_bottom_sheet.dart';
|
||||
import '../layout_define.dart';
|
||||
import 'ai_change_model_bottom_sheet.dart';
|
||||
import 'ai_message_action_bar.dart';
|
||||
import 'ai_change_format_bottom_sheet.dart';
|
||||
import 'message_util.dart';
|
||||
@@ -42,6 +43,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
this.isSelectingMessages = false,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
@@ -51,6 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
final bool isSelectingMessages;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -74,6 +77,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -83,6 +87,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -92,6 +97,7 @@ class ChatAIMessageBubble extends StatelessWidget {
|
||||
message: message,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -104,12 +110,14 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -128,6 +136,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
|
||||
showDecoration: false,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
),
|
||||
),
|
||||
const VSpace(32.0),
|
||||
@@ -143,12 +152,14 @@ class ChatAIMessageHover extends StatefulWidget {
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
|
||||
@@ -218,22 +229,23 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
|
||||
setState(() => hoverActionBar = false);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 784,
|
||||
maxHeight: DesktopAIChatSizes.messageActionBarIconSize +
|
||||
DesktopAIChatSizes
|
||||
.messageHoverActionBarPadding.vertical,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 784,
|
||||
height: DesktopAIChatSizes.messageActionBarIconSize +
|
||||
DesktopAIChatSizes.messageHoverActionBarPadding.vertical,
|
||||
child: hoverBubble || hoverActionBar || overrideVisibility
|
||||
? AIMessageActionBar(
|
||||
message: widget.message,
|
||||
showDecoration: true,
|
||||
onRegenerate: widget.onRegenerate,
|
||||
onChangeFormat: widget.onChangeFormat,
|
||||
onOverrideVisibility: (visibility) {
|
||||
overrideVisibility = visibility;
|
||||
},
|
||||
? Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: AIMessageActionBar(
|
||||
message: widget.message,
|
||||
showDecoration: true,
|
||||
onRegenerate: widget.onRegenerate,
|
||||
onChangeFormat: widget.onChangeFormat,
|
||||
onChangeModel: widget.onChangeModel,
|
||||
onOverrideVisibility: (visibility) {
|
||||
overrideVisibility = visibility;
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
@@ -303,12 +315,14 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
required this.message,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Message message;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -329,6 +343,8 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
_divider(),
|
||||
_changeFormatButton(context),
|
||||
_divider(),
|
||||
_changeModelButton(context),
|
||||
_divider(),
|
||||
_saveToPageButton(context),
|
||||
],
|
||||
);
|
||||
@@ -360,8 +376,7 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
}
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.grid_url_copiedNotification.tr(),
|
||||
message: LocaleKeys.message_copy_success.tr(),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -377,7 +392,7 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
onRegenerate?.call();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: FlowySvgs.ai_undo_s,
|
||||
icon: FlowySvgs.ai_try_again_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.chat_regenerate.tr(),
|
||||
);
|
||||
@@ -400,6 +415,25 @@ class ChatAIMessagePopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _changeModelButton(BuildContext context) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
|
||||
final result = await showChangeModelBottomSheet(context, models);
|
||||
if (result != null) {
|
||||
onChangeModel?.call(result);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: FlowySvgs.ai_sparks_s,
|
||||
iconSize: const Size.square(20),
|
||||
text: LocaleKeys.chat_switchModel_label.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _saveToPageButton(BuildContext context) {
|
||||
return MobileQuickActionButton(
|
||||
onTap: () async {
|
||||
@@ -470,7 +504,9 @@ class _WrapIsSelectingMessage extends StatelessWidget {
|
||||
if (isSelectingMessages)
|
||||
ChatSelectMessageIndicator(isSelected: isSelected)
|
||||
else
|
||||
const ChatAIAvatar(),
|
||||
SelectionContainer.disabled(
|
||||
child: const ChatAIAvatar(),
|
||||
),
|
||||
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
|
||||
Expanded(
|
||||
child: IgnorePointer(
|
||||
|
||||
+32
-7
@@ -1,9 +1,10 @@
|
||||
import 'package:appflowy/ai/widgets/loading_indicator.dart';
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
@@ -32,9 +33,11 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
required this.questionId,
|
||||
required this.chatId,
|
||||
required this.refSourceJsonString,
|
||||
required this.onStopStream,
|
||||
this.onSelectedMetadata,
|
||||
this.onRegenerate,
|
||||
this.onChangeFormat,
|
||||
this.onChangeModel,
|
||||
this.isLastMessage = false,
|
||||
this.isStreaming = false,
|
||||
this.isSelectingMessages = false,
|
||||
@@ -50,7 +53,9 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final String? refSourceJsonString;
|
||||
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
|
||||
final void Function()? onRegenerate;
|
||||
final void Function() onStopStream;
|
||||
final void Function(PredefinedFormat)? onChangeFormat;
|
||||
final void Function(AIModelPB)? onChangeModel;
|
||||
final bool isStreaming;
|
||||
final bool isLastMessage;
|
||||
final bool isSelectingMessages;
|
||||
@@ -84,14 +89,20 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
),
|
||||
ready: () {
|
||||
return state.text.isEmpty
|
||||
? ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
)
|
||||
: ChatAIMessageBubble(
|
||||
message: message,
|
||||
@@ -102,14 +113,19 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AIMarkdownText(markdown: state.text),
|
||||
AIMarkdownText(
|
||||
markdown: state.text,
|
||||
),
|
||||
if (state.sources.isNotEmpty)
|
||||
AIMessageMetadata(
|
||||
sources: state.sources,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
SelectionContainer.disabled(
|
||||
child: AIMessageMetadata(
|
||||
sources: state.sources,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
),
|
||||
),
|
||||
if (state.sources.isNotEmpty && !isLastMessage)
|
||||
const VSpace(8.0),
|
||||
@@ -138,6 +154,15 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys
|
||||
.settings_aiPage_keys_localAIInitializing
|
||||
.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart';
|
||||
void openPageFromMessage(BuildContext context, ViewPB? view) {
|
||||
if (view == null) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
|
||||
type: ToastificationType.error,
|
||||
);
|
||||
@@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
|
||||
return;
|
||||
}
|
||||
showToastNotification(
|
||||
context,
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
|
||||
+11
-25
@@ -15,13 +15,11 @@ class ChatUserMessageBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.child,
|
||||
required this.isCurrentUser,
|
||||
this.files = const [],
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final Widget child;
|
||||
final bool isCurrentUser;
|
||||
final List<ChatFile> files;
|
||||
|
||||
@override
|
||||
@@ -44,40 +42,28 @@ class ChatUserMessageBubble extends StatelessWidget {
|
||||
const VSpace(6),
|
||||
],
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: getChildren(context),
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_buildBubble(context),
|
||||
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
|
||||
_buildAvatar(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> getChildren(BuildContext context) {
|
||||
if (isCurrentUser) {
|
||||
return [
|
||||
const Spacer(),
|
||||
_buildBubble(context),
|
||||
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
|
||||
_buildAvatar(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
_buildAvatar(),
|
||||
const HSpace(DesktopAIChatSizes.avatarAndChatBubbleSpacing),
|
||||
_buildBubble(context),
|
||||
const Spacer(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
return BlocBuilder<ChatMemberBloc, ChatMemberState>(
|
||||
builder: (context, state) {
|
||||
final member = state.members[message.author.id];
|
||||
return ChatUserAvatar(
|
||||
iconUrl: member?.info.avatarUrl ?? "",
|
||||
name: member?.info.name ?? "",
|
||||
return SelectionContainer.disabled(
|
||||
child: ChatUserAvatar(
|
||||
iconUrl: member?.info.avatarUrl ?? "",
|
||||
name: member?.info.name ?? "",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Loaded 100 of 851 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user