Compare commits

...
46 Commits
Author SHA1 Message Date
Morn 02c86b13d9 chore: update CHANGELOG.md (#7930) 2025-05-14 18:35:50 +08:00
Richard Shiue 13fe4a2395 fix(mobile): initialize scale factor on startup (#7929)
* fix(mobile): initialize scale factor on startup

* fix: widget test
2025-05-13 21:50:37 +08:00
Lucas e8b8644716 chore: bump version 0.9.2 (#7926) 2025-05-13 14:11:32 +08:00
Morn 10472475ac fix: allow ':keyword' make mistakes (#7905) 2025-05-13 13:54:28 +08:00
Lucas 20e10e6bf2 chore: remove unused code (#7924) 2025-05-13 11:47:08 +08:00
Morn cf14a37dcf fix: using the shortcut to show the search panel will hide the 'Ask AI anything' button (#7916)
* fix: show search panel by shortcut will hide the 'Ask for AI' button

* fix: hovering on the search result will hide the border

* fix: improve the logic of updating CommandPalette value

* fix: UI issues

* fix: improve the behavior of search result preview

* chore: add integration test for desktop searching

* feat: enable click search result preview to open the page
2025-05-13 11:37:03 +08:00
Nathan.fooo a359313974 chore: select source when user is not anon (#7921) 2025-05-13 11:10:32 +08:00
Nathan 636794b9ba chore: event for fetch workspace plan 2025-05-13 00:13:57 +08:00
Nathan.fooo 7e5b243e40 chore: remove unused ai code (#7919) 2025-05-13 00:13:25 +08:00
Richard Shiue 5e99eeb167 chore: improve empty ai prompt list placeholder (#7917) 2025-05-12 16:53:06 +08:00
Morn 5029776685 fix: LR issues of desktop search (#7914)
* fix: update error view for search preview

* chore: add some logs for command palette

* fix: alignment issue
2025-05-12 14:12:30 +08:00
Morn 79009d2851 fix: the link name should be a required field (#7915) 2025-05-12 13:43:22 +08:00
NikitamceandRichard Shiue d153b3d9f1 chore(i18n): update ru-RU translations (#7835)
* chore: update translations with Fink 🐦

* Update ru-RU.json

* Update ru-RU.json

* chore: update translations with Fink 🐦

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 12:17:58 +08:00
CrEagleOneandRichard Shiue f9aa3ff499 chore(i18n): update fr-FR translations 🐦 (#7832)
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 11:46:03 +08:00
Mathias Mogensen b4fbfaf2f8 fix: drop files duplicates (#7620) 2025-05-12 11:25:56 +08:00
FakhriAzzouz a026367f85 chore(i18n): add ar-SA translations (#7800)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-05-12 11:13:14 +08:00
Tom GoldenandRichard Shiue ef7063b8fd chore(i18n): add en-GB translations (#7705)
* feat: added en-GB translations

* feat: added en-GB translations

* chore: remove redundant argument

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 11:10:11 +08:00
Richard Shiue 808bee5eb2 chore: adjust view selector logic (#7913)
* chore: adjust view selector logic

* chore: click on document to expand

* chore: code cleanup
2025-05-12 10:17:50 +08:00
Nathan 9ecae0a643 chore: ai templte 2025-05-11 20:16:12 +08:00
Nathan.fooo dfc80e6a5a chore: fetch subscription when start (#7910)
* chore: fetch subscription when start

* chore: fetch subscription if billing is enabled
2025-05-11 19:39:24 +08:00
Morn 014928cf19 feat: desktop Search UI revamp (#7895)
* feat: add ai search entrance

* chore: revamp search recent list

* feat: revamp preview widget

* feat: support click the 'Ask ai button'

* feat: support go to trash when there is no search results

* feat: support AI overview

* fix: typo issue

* fix: empty trash item name

* chore: replace some Dividers to AFDividers

* chore: improve some code logic

* chore: update some icon color

* fix: some UI issue

* chore: adjust some paddings

* fix: align issue for reference sources

* fix: change clear icon's hover effect

* fix: clear search field will not make it unfocus

* chore: make 'Ask AI' button scroll with list

* chore: change the size of search panel

* fix: change mobile search field cancel icon

* feat: support preview panel for recent pages

* fix: some UI issues

* fix: hover to show the reference sources

* fix: some padding issues
2025-05-11 16:45:44 +08:00
Richard Shiue 30f5692818 chore: adjust database custom prompt ui (#7902) 2025-05-11 16:45:28 +08:00
Nathan.foooandLucas.Xu 6e850ab8b2 refactor: extract event handler (#7908)
Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-11 16:44:59 +08:00
Khor Shu Heng 370b5714a1 chore: update collab version for row deserialization fix (#7907) 2025-05-11 14:08:32 +08:00
Nathan.fooo 2c8060cb1b refactor: chat bloc (#7904)
* refactor: chat bloc

* chore: clippy

* chore: make temp message id private
2025-05-10 22:07:47 +08:00
Nathan.fooo b4d48a63b9 chore: support multiple source search in chat (#7906)
* chore: support multiple source search in chat

* chore: clippy
2025-05-10 15:28:38 +08:00
Nathan.foooandLucas.Xu c986e414f4 Vault workspace suggested question (#7903)
* chore: return suggested question when ai say i don't know

* chore: new message type

* chore: implement uI

* chore: implement UI

* fix: assertion in local ai chat

* chore: implement UI

* chore: update prompt

* chore: update logs

* chore: add tests

* chore: message id

* fix: local ai page animation issue

* chore: remove debug log

* fix: remove repeated setState

* chore: add test

* chore: test

* fix: compile

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-09 21:58:23 +08:00
Lucas c79ac3290e test: simple ai chat tests (#7901)
* fix: repeated setState in animation list

* test: add simple chat integration test

* chore: update chat list

* test: send messages without default messages

* fix: refresh space after moving page
2025-05-09 21:40:06 +08:00
Richard Shiue 7f7657edc8 test: add view selector tests (#7900) 2025-05-09 17:53:23 +08:00
Lucas d3ce59fe08 fix: v0.9.2 launch review issues (#7898)
* fix: remove empty paragraphs after inserting file block

* fix: chat page animation

* fix: remove opacity from locked page icon

* feat: open the space after opening the view

* fix: chat page loading issue

* fix: view title assertion

* fix: cover title issue

* fix: flutter analyze

* fix: list state issue

* fix: reverse animation list issue

* fix: integration test
2025-05-09 13:02:19 +08:00
Richard Shiue 0f89b6f690 fix: selected sources changes not being updated correctly (#7897) 2025-05-08 14:38:38 +08:00
Richard Shiue 680015ef66 feat: load custom prompts from a database (#7894)
* feat: load custom prompts from a database

* chore: code review

* chore: process trashed views and reduce widget rebuilds
2025-05-08 11:56:30 +08:00
Richard Shiue b048407b4e fix: select option logic (#7893)
* test: add select option tests

* fix: select option logic

* test: fix existing tests

* test: fix test logic
2025-05-08 09:13:03 +08:00
Nathan.fooo 95f9b07ef7 chore: use tanvity as fallback ai search (#7892)
* chore: use tanvity as fallback ai search

* chore: fix test
2025-05-08 00:17:02 +08:00
Richard Shiue b1d3553110 refactor: view selector (#7891)
* refactor: extract common view selection logic

* refactor: extract common component

* chore: make selected section selectable

* chore: max num of selected top-level pages
2025-05-07 21:10:23 +08:00
Lucas 38839b4255 feat: improve chat message animations (#7890)
* chore: upgrade editor version

* chore: only print the reminder refresh log when it contains new updates

* fix: google font family name issue

* chore: i18n

* feat: improve chat ai animation
2025-05-07 20:32:13 +08:00
Nathan.fooo f7b556677f Custom langchain rust (#7887)
* chore: implement custom memory store chain

* chore: do not allow edit embedding model
2025-05-07 13:37:44 +08:00
Lucas 19b1bccb03 chore: upgrade editor version (#7886) 2025-05-07 11:03:47 +08:00
Nathan.fooo 0f5699571c chore: filter out space when indexing search (#7882) 2025-05-06 20:49:44 +08:00
Nathan.fooo 53cbd22111 chore: local ai chat with pages (#7881)
* chore: local ai chat with page

* chore: temp

* chore: clippy

* chore: fix ui bugs

* chore: fix ui bugs

* chore: fix view title

* chore: cloud id

* chore: bump server

* chore: bump server
2025-05-06 20:32:10 +08:00
Lucas 926a5b31bb refacotr: ai chat page folder strcture (#7879)
* refacotr: ai chat page folder strcture

* feat: add non-reversed animated chat list

* chore: add scroll animation

* feat: add fade animation

* fix: refresh issue

* feat: add flag to control the ai chat animation

* chore: enable reversed list
2025-05-06 13:10:14 +08:00
Nathan.fooo bdac73e934 chore: refactor local ai (#7880)
* chore: refactor local ai

* chore: ios build
2025-05-05 20:27:29 +08:00
Nathan.fooo 20f3116bd9 refactor: Model select (#7875)
* refactor: model select

* refactor: add test

* fix: add source

* fix: add source

* chore: notify all unset source

* chore: fix test
2025-05-02 15:37:40 +08:00
Lucas a2b1f8c325 chore: pick commits from release/0.9.1 (#7874)
* chore: update i18n

* chore: remove sentry

* fix: allow the member to get the workspace usage

* fix: integration test
2025-05-02 14:48:26 +08:00
Nathan.fooo 612d652320 feat: support local ai embedding, local ai search, local document content search (#7839)
* chore: generate embeddings

* chore: save embedding

* chore: vec sqlite

* chore: clippy

* chore: init vector

* chore: create vector db

* chore: periodically write embedding

* chore: fix compile

* chore: skip write

* chore: impl search

* fix: test

* fix: stop scheduler

* fix: search

* chore: add test

* chore: update schema

* chore: index all

* chore: index

* chore: search document content

* chore: index document content and title

* chore: index all view

* chore: rename trait

* refactor: remove folder index manager

* chore: index folder changes

* chore: delete folder search

* chore: update logs

* chore: update logs

* chore: search

* chore: add search test

* chore: fmt

* chore: fix test

* chore: fix ios build
2025-05-02 08:21:42 +08:00
Nathan.foooandLucas.Xu 7750f2257f fix: anon user can not locate its data (#7873)
* fix: anon user can not locate its data

* chore: add test

* chore: bump version 0.9.1

* chore: remove self-hosted ios ci

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-01 15:06:56 +08:00
392 changed files with 27135 additions and 11043 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+2 -2
View File
@@ -26,9 +26,9 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.37-amd64
CLOUD_VERSION: 0.9.49-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+1 -22
View File
@@ -19,35 +19,14 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-self-hosted:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Build AppFlowy
working-directory: frontend
run: |
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: "iPhone 15"
shutdown_after_job: false
integration-tests:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: macos-latest
steps:
+11 -11
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
jobs:
create-release:
@@ -232,10 +232,10 @@ jobs:
matrix:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -336,12 +336,12 @@ jobs:
matrix:
job:
- {
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
+3 -3
View File
@@ -18,8 +18,8 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.8.3-amd64
RUST_TOOLCHAIN: "1.81.0"
CLOUD_VERSION: 0.9.49-amd64
RUST_TOOLCHAIN: "1.84.0"
jobs:
ubuntu-job:
@@ -106,7 +106,7 @@ jobs:
af_cloud_test_ws_url: ws://localhost/ws/v1
af_cloud_test_gotrue_url: http://localhost/gotrue
run: |
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" -- --skip local_ollama_test
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
jobs:
tests:
+35
View File
@@ -1,4 +1,39 @@
# Release Notes
## Version 0.9.2 - 14/05/2025
### Desktop
#### New Features
- Supported AI Overview in Search to answer user queries based on their entire workspace
- Revamped the Search panel in the desktop app
- Enabled loading custom prompts from an AppFlowy database page
#### Bug Fixes
- Improved inserting emojis using the colon (:)
- Supported automatically filling the link name with the URL if the name is left empty
### Mobile
#### Bug Fixes
- Supported automatically filling the link name with the URL if the name is left empty
## Version 0.9.1 - 01/05/2025
### Desktop
#### New Features
- Added AppFlowy Prompt Library to AI Chat and Document's Ask AI
- Revamped the desktop in-app notification center
- Supported login with password, as well as forgot and change password options
- Supported copying link to invite members
- Improved the Settings' Members tab with new metadata: member avatar and joined time
#### Bug Fixes
- Fixed data loss when using anonymous local
- Fixed crash when trying to delete an emoji
- Fixed Windows scaling issue
- Correctly displayed mention text by decoding web content
### Mobile
#### New Features
- Supported workspace search
- Improved UX for links in documents
- Supported changing password in Mobile Settings
- Added support for inviting members via links
#### Bug Fixes
- Correctly displayed mention text by decoding web content
## Version 0.9.0 - 30/04/2025
### Desktop
#### New Features
+36 -35
View File
@@ -1,16 +1,16 @@
#https://github.com/sagiegurari/cargo-make
extend = [
{ path = "scripts/makefile/desktop.toml" },
{ path = "scripts/makefile/mobile.toml" },
{ path = "scripts/makefile/protobuf.toml" },
{ path = "scripts/makefile/tests.toml" },
{ path = "scripts/makefile/docker.toml" },
{ path = "scripts/makefile/env.toml" },
{ path = "scripts/makefile/flutter.toml" },
{ path = "scripts/makefile/tool.toml" },
{ path = "scripts/makefile/tauri.toml" },
{ path = "scripts/makefile/web.toml" },
{ path = "scripts/makefile/desktop.toml" },
{ path = "scripts/makefile/mobile.toml" },
{ path = "scripts/makefile/protobuf.toml" },
{ path = "scripts/makefile/tests.toml" },
{ path = "scripts/makefile/docker.toml" },
{ path = "scripts/makefile/env.toml" },
{ path = "scripts/makefile/flutter.toml" },
{ path = "scripts/makefile/tool.toml" },
{ path = "scripts/makefile/tauri.toml" },
{ path = "scripts/makefile/web.toml" },
]
[config]
@@ -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.9.0"
APPFLOWY_VERSION = "0.9.2"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -228,30 +228,31 @@ script = ['''
echo APP_ENVIRONMENT: ${APP_ENVIRONMENT}
echo BUILD_ARCHS: ${BUILD_ARCHS}
echo BUILD_VERSION: ${BUILD_VERSION}
echo RUST_VERSION: "$(rustc --version)"
''']
script_runner = "@shell"
[tasks.setup-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
[tasks.restore-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
@@ -279,24 +280,24 @@ TEST_COMPILE_TARGET = "x86_64-pc-windows-msvc"
[tasks.setup-test-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
[tasks.restore-test-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
@@ -8,7 +8,7 @@
"{""data"":""Juan"",""last_modified"":1726063423,""created_at"":1726063423,""field_type"":0}","{""created_at"":1726122510,""reminder_id"":"""",""include_time"":false,""is_range"":true,""last_modified"":1726122515,""data"":""1725604115"",""end_timestamp"":""1725776915"",""field_type"":2}","{""field_type"":1,""created_at"":1726063677,""last_modified"":1726063677,""data"":""93100""}","{""field_type"":1,""data"":""4.86"",""created_at"":1726062597,""last_modified"":1726062597}",,"{""last_modified"":1726062377,""field_type"":5,""data"":""Yes"",""created_at"":1726062377}","{""last_modified"":1726064412,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""tTDq\"",\""name\"":\""complete onboarding\"",\""color\"":\""Purple\""},{\""id\"":\""E8Ds\"",\""name\"":\""contact support\"",\""color\"":\""Purple\""},{\""id\"":\""RoGN\"",\""name\"":\""get started\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""tTDq\"",\""E8Ds\""]}"",""created_at"":1726064396}",,"{""created_at"":1726065278,""field_type"":3,""data"":""qnja"",""last_modified"":1726065278}","{""data"":""R9I7,yORP,1i4f"",""field_type"":4,""created_at"":1726105126,""last_modified"":1726105127}","{""data"":""1726122515"",""field_type"":8}","{""data"":""1726060541"",""field_type"":9}"
"{""data"":""Alex"",""created_at"":1726063432,""last_modified"":1726063432,""field_type"":0}","{""reminder_id"":"""",""data"":""1725292800"",""include_time"":true,""last_modified"":1726122448,""created_at"":1726122422,""is_range"":true,""end_timestamp"":""1725551940"",""field_type"":2}","{""field_type"":1,""last_modified"":1726063683,""created_at"":1726063683,""data"":""3560""}","{""created_at"":1726062561,""data"":""1.96"",""last_modified"":1726062561,""field_type"":1}","{""last_modified"":1726063952,""created_at"":1726063931,""data"":""al3x1343@protonmail.com"",""field_type"":6}","{""last_modified"":1726062375,""field_type"":5,""created_at"":1726062375,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""qNyr\"",\""name\"":\""finish reading book\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064616,""last_modified"":1726064616,""field_type"":7}",,"{""data"":""qnja"",""created_at"":1726065272,""last_modified"":1726065272,""field_type"":3}","{""created_at"":1726105180,""last_modified"":1726105180,""field_type"":4,""data"":""R9I7,1i4f""}","{""field_type"":8,""data"":""1726122448""}","{""field_type"":9,""data"":""1726060541""}"
"{""last_modified"":1726063478,""created_at"":1726063436,""field_type"":0,""data"":""Alexander""}",,"{""field_type"":1,""last_modified"":1726063691,""created_at"":1726063691,""data"":""2073""}","{""field_type"":1,""data"":""0.5"",""last_modified"":1726062577,""created_at"":1726062577}","{""last_modified"":1726063991,""field_type"":6,""created_at"":1726063991,""data"":""alexandernotthedra@gmail.com""}","{""field_type"":5,""last_modified"":1726062378,""created_at"":1726062377,""data"":""No""}",,,"{""created_at"":1726065291,""data"":""GSf_"",""last_modified"":1726065291,""field_type"":3}","{""last_modified"":1726105142,""created_at"":1726105133,""data"":""SEUo"",""field_type"":4}","{""field_type"":8,""data"":""1726105142""}","{""field_type"":9,""data"":""1726060542""}"
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://static.cdninstagram.com/rsrc.php/v3/ym/r/BQdTmxpRI6f.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://file-examples.com/wp-content/storage/2017/10/file_example_PNG_500kB.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
"{""field_type"":0,""last_modified"":1726063467,""data"":""Joanna"",""created_at"":1726063467}","{""include_time"":false,""end_timestamp"":""1727072893"",""is_range"":true,""last_modified"":1726122493,""created_at"":1726122483,""data"":""1726554493"",""field_type"":2,""reminder_id"":""""}","{""last_modified"":1726065463,""data"":""16470"",""field_type"":1,""created_at"":1726065463}","{""created_at"":1726062626,""field_type"":1,""last_modified"":1726062626,""data"":""-5.36""}","{""last_modified"":1726064069,""data"":""joannastrawberry29+hello@gmail.com"",""created_at"":1726064069,""field_type"":6}",,"{""field_type"":7,""created_at"":1726064444,""last_modified"":1726064460,""data"":""{\""options\"":[{\""id\"":\""ZxJz\"",\""name\"":\""post on Twitter\"",\""color\"":\""Purple\""},{\""id\"":\""upwi\"",\""name\"":\""watch Youtube videos\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""upwi\""]}""}",,"{""created_at"":1726065317,""last_modified"":1726065317,""field_type"":3,""data"":""qnja""}","{""field_type"":4,""last_modified"":1726105173,""data"":""uRAO,yORP"",""created_at"":1726105170}","{""data"":""1726122493"",""field_type"":8}","{""data"":""1726060545"",""field_type"":9}"
"{""last_modified"":1726063457,""created_at"":1726063457,""data"":""George"",""field_type"":0}","{""include_time"":true,""reminder_id"":"""",""field_type"":2,""is_range"":true,""created_at"":1726122521,""end_timestamp"":""1725829200"",""data"":""1725822900"",""last_modified"":1726122535}","{""last_modified"":1726065493,""field_type"":1,""data"":""9500"",""created_at"":1726065493}","{""last_modified"":1726062680,""created_at"":1726062680,""field_type"":1,""data"":""1.7""}","{""data"":""plgeorgebball@gmail.com"",""field_type"":6,""last_modified"":1726064087,""created_at"":1726064036}",,"{""last_modified"":1726064513,""data"":""{\""options\"":[{\""id\"":\""zy0x\"",\""name\"":\""game vs celtics\"",\""color\"":\""Purple\""},{\""id\"":\""WJsv\"",\""name\"":\""training\"",\""color\"":\""Purple\""},{\""id\"":\""w-f8\"",\""name\"":\""game vs spurs\"",\""color\"":\""Purple\""},{\""id\"":\""p1VQ\"",\""name\"":\""game vs knicks\"",\""color\"":\""Purple\""},{\""id\"":\""VjUA\"",\""name\"":\""recovery\"",\""color\"":\""Purple\""},{\""id\"":\""sQ8X\"",\""name\"":\""don't get injured\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064486,""field_type"":7}",,"{""field_type"":3,""last_modified"":1726065310,""data"":""qnja"",""created_at"":1726065310}","{""created_at"":1726105205,""field_type"":4,""last_modified"":1726105249,""data"":""R9I7,1i4f,yORP,SEUo""}","{""data"":""1726122535"",""field_type"":8}","{""field_type"":9,""data"":""1726060546""}"
"{""data"":""Judy"",""created_at"":1726063475,""field_type"":0,""last_modified"":1726063487}","{""end_timestamp"":"""",""reminder_id"":"""",""data"":""1726640950"",""field_type"":2,""include_time"":false,""created_at"":1726122550,""last_modified"":1726122550,""is_range"":false}",,,"{""created_at"":1726063882,""field_type"":6,""last_modified"":1726064000,""data"":""judysmithjr@outlook.com""}","{""last_modified"":1726062712,""field_type"":5,""data"":""Yes"",""created_at"":1726062712}","{""created_at"":1726064549,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""j8cC\"",\""name\"":\""finish training\"",\""color\"":\""Purple\""},{\""id\"":\""SmSk\"",\""name\"":\""brainwash\"",\""color\"":\""Purple\""},{\""id\"":\""mnf5\"",\""name\"":\""welcome to ba sing se\"",\""color\"":\""Purple\""},{\""id\"":\""hcrj\"",\""name\"":\""don't mess up\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""j8cC\"",\""SmSk\"",\""mnf5\"",\""hcrj\""]}"",""last_modified"":1726064591}",,,"{""field_type"":4,""last_modified"":1726105152,""created_at"":1726105152,""data"":""R9I7""}","{""field_type"":8,""data"":""1726122550""}","{""field_type"":9,""data"":""1726060549""}"
@@ -0,0 +1,264 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/ai_test_op.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('chat page:', () {
testWidgets('send messages with default messages', (tester) async {
skipAIChatWelcomePage = true;
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '0';
final aiUser = User(id: aiUserId, lastName: 'AI');
await tester.loadDefaultMessages(
[
Message.text(
id: '1746776401',
text: 'How to use Kanban to manage tasks?',
author: user,
createdAt: DateTime.now().add(const Duration(seconds: 1)),
),
Message.text(
id: '1746776401_ans',
text:
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now().add(const Duration(seconds: 2)),
),
Message.text(
id: '1746776402',
text: 'How to use Kanban to manage tasks?',
author: user,
createdAt: DateTime.now().add(const Duration(seconds: 3)),
),
Message.text(
id: '1746776402_ans',
text:
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now().add(const Duration(seconds: 4)),
),
].reversed.toList(),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// start chat
final int messageId = 1;
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
final chatBloc = tester.getCurrentChatBloc();
expect(chatBloc.chatController.messages.length, equals(6));
});
testWidgets('send messages without default messages', (tester) async {
skipAIChatWelcomePage = true;
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '0';
final aiUser = User(id: aiUserId, lastName: 'AI');
// start chat
int messageId = 1;
// round 1
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 2
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 3
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'What document formatting options are available?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# AppFlowy Document Formatting\n- Basic formatting: Bold, italic, underline, strikethrough\n- Headings: 6 levels of headings for structuring content\n- Lists: Bullet points, numbered lists, and checklists\n- Code blocks: Format text as code with syntax highlighting\n- Tables: Create and format data tables\n- Embedded content: Add images, files, and other rich media',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 4
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How do I export my data from AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# Exporting from AppFlowy\n- Export documents in multiple formats: Markdown, HTML, PDF\n- Export databases as CSV or Excel files\n- Batch export entire workspaces for backup\n- Use the export menu (three dots → Export) on any page\n- Exported files maintain most formatting and structure',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 5
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'Is there a mobile version of AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# AppFlowy on Mobile\n- Yes, AppFlowy is available for iOS and Android devices\n- Download from the App Store or Google Play Store\n- Mobile app includes core functionality: document editing, databases, and boards\n- Offline mode allows working without internet connection\n- Sync automatically when you reconnect\n- Responsive design adapts to different screen sizes',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
final chatBloc = tester.getCurrentChatBloc();
expect(chatBloc.chatController.messages.length, equals(10));
});
});
}
@@ -4,6 +4,7 @@ import 'database/database_test_runner.dart' as database_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
import 'sidebar/sidebar_search_test.dart' as sidebar_search_test;
import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
import 'sidebar/sidebar_rename_untitled_test.dart'
as sidebar_rename_untitled_test;
@@ -31,6 +32,7 @@ Future<void> main() async {
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
sidebar_search_test.main();
}
// database
@@ -0,0 +1,42 @@
import 'package:appflowy/ai/widgets/prompt_input/desktop_prompt_input.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/util.dart';
void main() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
});
testWidgets('Test for searching', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
/// show searching page
final searchingButton = find.text(LocaleKeys.search_label.tr());
await tester.tapButton(searchingButton);
final askAIButton = find.byType(SearchAskAiEntrance);
expect(askAIButton, findsOneWidget);
/// searching for [gettingStarted]
final searchField = find.byType(SearchField);
final textFiled =
find.descendant(of: searchField, matching: find.byType(TextField));
await tester.enterText(textFiled, gettingStarted);
await tester.pumpAndSettle(Duration(seconds: 1));
/// tap ask AI button
await tester.tapButton(askAIButton);
expect(find.byType(DesktopPromptInput), findsOneWidget);
});
}
@@ -1,7 +1,17 @@
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/page_preview.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import '../../shared/util.dart';
void main() {
@@ -19,4 +29,70 @@ void main() {
expect(find.byType(CommandPaletteModal), findsNothing);
});
});
group('Search', () {
testWidgets('Test for searching', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'Switch To New Page');
await tester.pumpAndSettle();
/// tap getting started page
await tester.tapButton(
find.descendant(
of: find.byType(HomeSideBar),
matching: find.textContaining(gettingStarted),
),
);
/// show searching page
final searchingButton = find.text(LocaleKeys.search_label.tr());
await tester.tapButton(searchingButton);
final askAIButton = find.byType(SearchAskAiEntrance);
expect(askAIButton, findsNothing);
final recentList = find.byType(RecentViewsList);
expect(recentList, findsOneWidget);
/// there is [gettingStarted] in recent list
final gettingStartedRecentCell = find.descendant(
of: recentList,
matching: find.textContaining(gettingStarted),
);
expect(gettingStartedRecentCell, findsAtLeast(1));
/// hover to show preview
await tester.hoverOnWidget(gettingStartedRecentCell.first);
final pagePreview = find.byType(PagePreview);
expect(pagePreview, findsOneWidget);
final gettingStartedPreviewTitle = find.descendant(
of: pagePreview,
matching: find.textContaining(gettingStarted),
);
expect(gettingStartedPreviewTitle, findsOneWidget);
/// searching for [gettingStarted]
final searchField = find.byType(SearchField);
final textFiled =
find.descendant(of: searchField, matching: find.byType(TextField));
await tester.enterText(textFiled, gettingStarted);
await tester.pumpAndSettle(Duration(seconds: 1));
/// there is [gettingStarted] in result list
final resultList = find.byType(SearchResultList);
expect(resultList, findsOneWidget);
final resultCells = find.byType(SearchResultCell);
expect(resultCells, findsAtLeast(1));
/// hover to show preview
await tester.hoverOnWidget(resultCells.first);
expect(find.byType(PagePreview), findsOneWidget);
/// clear search content
final clearButton = find.byFlowySvg(FlowySvgs.search_clear_m);
await tester.tapButton(clearButton);
expect(find.byType(SearchResultList), findsNothing);
expect(find.byType(RecentViewsList), findsOneWidget);
});
});
}
@@ -366,5 +366,88 @@ void main() {
expect(getLinkFromNode(node), link);
expect(getNodeText(node), afterText);
});
testWidgets('insert link and clear link name', (tester) async {
const text = 'edit link', link = 'https://test.appflowy.cloud';
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, 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));
/// 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);
/// clear the link name
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, '');
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
node = tester.editor.getNodeAtPath([0]);
expect(getNodeText(node), link);
});
testWidgets('insert link and clear link name and remove link', (tester) async {
const text = 'edit link', link = 'https://test.appflowy.cloud';
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, 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));
/// 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);
/// clear the link name
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, '');
await tester.pumpAndSettle();
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
node = tester.editor.getNodeAtPath([0]);
expect(getNodeText(node), link);
expect(getLinkFromNode(node), null);
});
});
}
@@ -1,5 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/chat/chat_page_test.dart' as chat_page_test;
import 'desktop/database/database_icon_test.dart' as database_icon_test;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
@@ -17,4 +18,5 @@ Future<void> runIntegration9OnDesktop() async {
tabs_test.main();
code_language_selector.main();
database_icon_test.main();
chat_page_test.main();
}
@@ -1,9 +1,14 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test/util.dart';
import 'util.dart';
extension AppFlowyAITest on WidgetTester {
@@ -38,4 +43,27 @@ extension AppFlowyAITest on WidgetTester {
testTextInput.enterText(text);
await pumpAndSettle(const Duration(milliseconds: 300));
}
ChatBloc getCurrentChatBloc() {
return element(find.byType(ChatContentPage)).read<ChatBloc>();
}
Future<void> loadDefaultMessages(List<Message> messages) async {
final chatBloc = getCurrentChatBloc();
chatBloc.add(ChatEvent.didLoadLatestMessages(messages));
await blocResponseFuture();
}
Future<void> sendUserMessage(Message message) async {
final chatBloc = getCurrentChatBloc();
// using received message to simulate the user message
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
Future<void> receiveAIMessage(Message message) async {
final chatBloc = getCurrentChatBloc();
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
}
@@ -1051,6 +1051,8 @@ extension ViewLayoutPBTest on ViewLayoutPB {
return LocaleKeys.document_menuName.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.calendar_menuName.tr();
case ViewLayoutPB.Chat:
return LocaleKeys.chat_newChat.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
@@ -71,11 +71,6 @@ PODS:
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -112,7 +107,6 @@ DEPENDENCIES:
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@@ -126,7 +120,6 @@ SPEC REPOS:
- DKPhotoGallery
- ReachabilitySwift
- SDWebImage
- Sentry
- SwiftyGif
- Toast
@@ -165,8 +158,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
saver_gallery:
:path: ".symlinks/plugins/saver_gallery/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -202,8 +193,6 @@ SPEC CHECKSUMS:
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+2
View File
@@ -5,7 +5,9 @@ export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'service/ai_model_state_notifier.dart';
export 'service/select_model_bloc.dart';
export 'service/view_selector_cubit.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/view_selector.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_prompt_input.dart';
export 'widgets/prompt_input/file_attachment_list.dart';
@@ -1,6 +1,7 @@
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-database2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
@@ -14,11 +15,12 @@ class AIStreamEventPrefix {
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';
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:';
static const aiFollowUp = 'ai_follow_up:';
}
enum AiType {
@@ -192,8 +194,23 @@ class AiPrompt extends Equatable {
required this.category,
required this.example,
required this.isFeatured,
required this.isCustom,
});
factory AiPrompt.fromPB(CustomPromptPB pb) {
return AiPrompt(
id: pb.id,
name: pb.name,
content: pb.content,
category: AiPromptCategory.values.firstWhere(
(e) => e.name == pb.category,
),
example: pb.example,
isFeatured: false,
isCustom: true,
);
}
factory AiPrompt.fromJson(Map<String, dynamic> json) =>
_$AiPromptFromJson(json);
@@ -211,7 +228,10 @@ class AiPrompt extends Equatable {
final String example;
@JsonKey(defaultValue: false)
final bool isFeatured;
@JsonKey(defaultValue: false)
final bool isCustom;
@override
List<Object?> get props => [id, name, content, category, example, isFeatured];
List<Object?> get props =>
[id, name, content, category, example, isFeatured, isCustom];
}
@@ -70,11 +70,6 @@ class AIModelStateNotifier {
stateCallback: (state) async {
_localAIState = state;
_updateAll();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadModelSelection();
_updateAll();
}
},
);
}
@@ -190,7 +185,7 @@ class AIModelStateNotifier {
}
final enabled = _localAIState!.enabled;
final running = _localAIState!.state == RunningStatePB.Running;
final running = _localAIState!.isReady;
final hintKey = enabled
? (running
? LocaleKeys.chat_inputLocalAIMessageHint
@@ -1,3 +1,7 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
@@ -14,7 +18,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
}) : _aiService = aiService ?? AppFlowyAIService(),
super(AiPromptSelectorState.loading()) {
filterTextController.addListener(_filterTextChanged);
_loadPrompts();
_init();
}
final AppFlowyAIService _aiService;
@@ -27,33 +31,131 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
await super.close();
}
void _loadPrompts() async {
void _init() async {
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
final featuredPrompts =
availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
emit(
AiPromptSelectorState.ready(
visiblePrompts: visiblePrompts,
showCustomPrompts: false,
isFeaturedCategorySelected: true,
visiblePrompts: visiblePrompts.toList(),
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
customPromptDatabaseViewId: null,
isLoadingCustomPrompts: true,
selectedCategory: null,
favoritePrompts: [],
),
);
loadCustomPrompts();
}
void selectFeaturedCategory() {
void loadCustomPrompts() {
state.maybeMap(
ready: (readyState) async {
emit(
readyState.copyWith(isLoadingCustomPrompts: true),
);
String? databaseViewId = readyState.customPromptDatabaseViewId;
if (databaseViewId == null) {
final result =
await AIEventGetCustomPromptDatabaseViewId().send().toNullable();
databaseViewId = result?.id;
}
if (databaseViewId == null) {
emit(
readyState.copyWith(isLoadingCustomPrompts: false),
);
return;
}
availablePrompts.removeWhere((prompt) => prompt.isCustom);
final customPrompts =
await _aiService.getDatabasePrompts(databaseViewId);
if (customPrompts == null) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
customPromptDatabaseViewId: databaseViewId,
isLoadingCustomPrompts: false,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
selectedCategory: null,
),
);
} else {
availablePrompts.addAll(customPrompts);
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
customPromptDatabaseViewId: databaseViewId,
isLoadingCustomPrompts: false,
selectedPromptId: selectedPromptId,
),
);
}
},
orElse: () {},
);
}
void selectCustomSection() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isCustom);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: visiblePrompts.firstOrNull?.id,
isCustomPromptSectionSelected: true,
isFeaturedSectionSelected: false,
selectedCategory: null,
),
);
},
orElse: () {},
);
}
void selectFeaturedSection() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts,
isFeaturedCategorySelected: true,
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: visiblePrompts.firstOrNull?.id,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
selectedCategory: null,
),
);
},
@@ -64,24 +166,23 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void selectCategory(AiPromptCategory? category) {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = category == null
final prompts = category == null
? availablePrompts
: availablePrompts.where((prompt) => prompt.category == category);
final prompts = _getFilteredPrompts(unfilteredPrompts);
final visiblePrompts = _getFilteredPrompts(prompts);
// Check if the selected prompt is still visible
String? selectedPromptId = prompts.firstOrNull?.id;
if (readyState.selectedPromptId != null &&
prompts.any((prompt) => prompt.id == readyState.selectedPromptId)) {
selectedPromptId = readyState.selectedPromptId;
}
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: prompts,
isFeaturedCategorySelected: false,
visiblePrompts: visiblePrompts.toList(),
selectedCategory: category,
selectedPromptId: selectedPromptId,
isFeaturedSectionSelected: false,
isCustomPromptSectionSelected: false,
),
);
},
@@ -116,8 +217,6 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(favoritePrompts: favoritePrompts),
);
// TODO(RS): Save the updated favorite prompts to local storage or database
},
orElse: () {},
);
@@ -130,7 +229,9 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(
visiblePrompts: availablePrompts,
selectedPromptId: null,
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: availablePrompts.firstOrNull?.id,
selectedCategory: null,
),
);
@@ -139,27 +240,84 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
);
}
void updateCustomPromptDatabaseViewId(String viewId) async {
final stateCopy = state;
final newState = state.maybeMap(
ready: (readyState) {
return readyState.customPromptDatabaseViewId == viewId
? null
: readyState.copyWith(isLoadingCustomPrompts: true);
},
orElse: () => null,
);
if (newState == null) {
return;
}
emit(newState);
await Future.delayed(const Duration(seconds: 1));
final customPrompts = await _aiService.getDatabasePrompts(viewId);
if (customPrompts == null) {
emit(AiPromptSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
availablePrompts
..removeWhere((prompt) => prompt.isCustom)
..addAll(customPrompts);
await AIEventSetCustomPromptDatabaseViewId(
CustomPromptDatabaseViewIdPB()..id = viewId,
).send().onFailure(Log.error);
emit(
state.maybeMap(
ready: (readyState) {
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
return readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
customPromptDatabaseViewId: viewId,
isLoadingCustomPrompts: false,
);
},
orElse: () => state,
),
);
}
void _filterTextChanged() {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = readyState.selectedCategory == null
? readyState.isFeaturedCategorySelected
? availablePrompts.where((prompt) => prompt.isFeatured)
: availablePrompts
: availablePrompts.where(
(prompt) => prompt.category == readyState.selectedCategory,
);
final filteredPrompts = _getFilteredPrompts(unfilteredPrompts);
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(visiblePrompts: filteredPrompts),
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
),
);
},
orElse: () {},
);
}
List<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
Iterable<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
final filterText = filterTextController.value.text.trim().toLowerCase();
return prompts.where((prompt) {
@@ -167,6 +325,33 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
return content.contains(filterText);
}).toList();
}
Iterable<AiPrompt> _getPromptsByCategory(_AiPromptSelectorReadyState state) {
return availablePrompts.where((prompt) {
if (state.selectedCategory != null) {
return prompt.category == state.selectedCategory;
}
if (state.isFeaturedSectionSelected) {
return prompt.isFeatured;
}
if (state.isCustomPromptSectionSelected) {
return prompt.isCustom;
}
return true;
});
}
String? _getVisibleSelectedPrompt(
Iterable<AiPrompt> visiblePrompts,
String? currentlySelectedPromptId,
) {
if (visiblePrompts
.any((prompt) => prompt.id == currentlySelectedPromptId)) {
return currentlySelectedPromptId;
}
return visiblePrompts.firstOrNull?.id;
}
}
@freezed
@@ -175,13 +360,18 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
const factory AiPromptSelectorState.loading() = _AiPromptSelectorLoadingState;
const factory AiPromptSelectorState.invalidDatabase() =
_AiPromptSelectorErrorState;
const factory AiPromptSelectorState.ready({
required List<AiPrompt> visiblePrompts,
required List<String> favoritePrompts,
required bool showCustomPrompts,
required bool isFeaturedCategorySelected,
required bool isCustomPromptSectionSelected,
required bool isFeaturedSectionSelected,
required AiPromptCategory? selectedCategory,
required String? selectedPromptId,
required bool isLoadingCustomPrompts,
required String? customPromptDatabaseViewId,
}) = _AiPromptSelectorReadyState;
bool get isLoading => this is _AiPromptSelectorLoadingState;
@@ -192,9 +382,4 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
orElse: () => null,
);
AiPromptCategory? get selectedCategory => maybeMap(
ready: (state) => state.selectedCategory,
orElse: () => null,
);
}
@@ -9,6 +9,7 @@ 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_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart' as fixnum;
@@ -41,6 +42,8 @@ abstract class AIRepository {
Future<List<AiPrompt>> getBuiltInPrompts();
Future<List<AiPrompt>?> getDatabasePrompts(String databaseViewId);
void updateFavoritePrompts(List<String> promptIds);
}
@@ -117,6 +120,23 @@ class AppFlowyAIService implements AIRepository {
return prompts;
}
@override
Future<List<AiPrompt>?> getDatabasePrompts(
String databaseViewId,
) async {
return DatabaseEventGetDatabaseCustomPrompts(
DatabaseViewIdPB(value: databaseViewId),
).send().fold(
(databasePromptsPB) {
return databasePromptsPB.items.map(AiPrompt.fromPB).toList();
},
(err) {
Log.error(err);
return null;
},
);
}
@override
void updateFavoritePrompts(List<String> promptIds) {}
}
@@ -1,3 +1,4 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
@@ -6,13 +7,12 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_select_sources_cubit.freezed.dart';
part 'view_selector_cubit.freezed.dart';
const int _kMaxSelectedParentPageCount = 3;
enum SourceSelectedStatus {
enum ViewSelectedStatus {
unselected,
selected,
partiallySelected;
@@ -22,45 +22,46 @@ enum SourceSelectedStatus {
bool get isPartiallySelected => this == partiallySelected;
}
class ChatSource {
ChatSource({
class ViewSelectorItem {
ViewSelectorItem({
required this.view,
required this.parentView,
required this.children,
required bool isExpanded,
required SourceSelectedStatus selectedStatus,
required IgnoreViewType ignoreStatus,
required ViewSelectedStatus selectedStatus,
required bool isDisabled,
}) : isExpandedNotifier = ValueNotifier(isExpanded),
selectedStatusNotifier = ValueNotifier(selectedStatus),
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
isDisabledNotifier = ValueNotifier(isDisabled);
final ViewPB view;
final ViewPB? parentView;
final List<ChatSource> children;
final List<ViewSelectorItem> children;
final ValueNotifier<bool> isExpandedNotifier;
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
final ValueNotifier<bool> isDisabledNotifier;
final ValueNotifier<ViewSelectedStatus> selectedStatusNotifier;
bool get isExpanded => isExpandedNotifier.value;
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
ViewSelectedStatus get selectedStatus => selectedStatusNotifier.value;
bool get isDisabled => isDisabledNotifier.value;
void toggleIsExpanded() {
isExpandedNotifier.value = !isExpanded;
isExpandedNotifier.value = !isExpandedNotifier.value;
}
ChatSource copy() {
return ChatSource(
ViewSelectorItem copy() {
return ViewSelectorItem(
view: view,
parentView: parentView,
children: children.map<ChatSource>((child) => child.copy()).toList(),
ignoreStatus: ignoreStatus,
isExpanded: isExpanded,
selectedStatus: selectedStatus,
children:
children.map<ViewSelectorItem>((child) => child.copy()).toList(),
isDisabled: isDisabledNotifier.value,
isExpanded: isExpandedNotifier.value,
selectedStatus: selectedStatusNotifier.value,
);
}
ChatSource? findChildBySourceId(String sourceId) {
ViewSelectorItem? findChildBySourceId(String sourceId) {
if (view.id == sourceId) {
return this;
}
@@ -73,20 +74,19 @@ class ChatSource {
return null;
}
void resetIgnoreViewTypeRecursive() {
ignoreStatusNotifier.value = view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable;
void setIsDisabledRecursive(bool Function(ViewSelectorItem) newIsDisabled) {
isDisabledNotifier.value = newIsDisabled(this);
for (final child in children) {
child.resetIgnoreViewTypeRecursive();
child.setIsDisabledRecursive(newIsDisabled);
}
}
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
ignoreStatusNotifier.value = newIgnoreViewType;
void setIsSelectedStatusRecursive(ViewSelectedStatus selectedStatus) {
selectedStatusNotifier.value = selectedStatus;
for (final child in children) {
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
child.setIsSelectedStatusRecursive(selectedStatus);
}
}
@@ -96,36 +96,45 @@ class ChatSource {
}
isExpandedNotifier.dispose();
selectedStatusNotifier.dispose();
ignoreStatusNotifier.dispose();
isDisabledNotifier.dispose();
}
}
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
ChatSettingsCubit({
this.hideDisabled = false,
}) : super(ChatSettingsState.initial());
class ViewSelectorCubit extends Cubit<ViewSelectorState> {
ViewSelectorCubit({
required this.getIgnoreViewType,
this.maxSelectedParentPageCount,
}) : super(ViewSelectorState.initial()) {
filterTextController.addListener(onFilterChanged);
}
final bool hideDisabled;
final IgnoreViewType Function(ViewSelectorItem) getIgnoreViewType;
final int? maxSelectedParentPageCount;
final List<String> selectedSourceIds = [];
final List<ChatSource> sources = [];
final List<ChatSource> selectedSources = [];
String filter = '';
final List<ViewSelectorItem> sources = [];
final List<ViewSelectorItem> selectedSources = [];
final filterTextController = TextEditingController();
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds.clear();
selectedSourceIds.addAll(newSelectedSourceIds);
}
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
filter = "";
Future<void> refreshSources(
List<ViewPB> spaceViews,
ViewPB? currentSpace,
) async {
filterTextController.clear();
final newSources = await Future.wait(
spaceViews.map((view) => _recursiveBuild(view, null)),
);
for (final source in newSources) {
_restrictSelectionIfNecessary(source.children);
}
_setIsDisabledAndHideIfNecessary(newSources);
_restrictSelectionIfNecessary(newSources);
if (currentSpace != null) {
newSources
.firstWhereOrNull((e) => e.view.id == currentSpace.id)
@@ -155,29 +164,31 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
..addAll(selected.map((e) => e.copy()));
}
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
Future<ViewSelectorItem> _recursiveBuild(
ViewPB view,
ViewPB? parentView,
) async {
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
final isThisSourceSelected = selectedSourceIds.contains(view.id);
final childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
final List<ViewPB>? childrenViews;
if (integrationMode().isTest) {
childrenViews = view.childViews;
} else {
childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
}
int selectedCount = 0;
final children = <ChatSource>[];
final children = <ViewSelectorItem>[];
if (childrenViews != null) {
for (final childView in childrenViews) {
if (childView.layout == ViewLayoutPB.Chat) {
continue;
}
if (childView.layout != ViewLayoutPB.Document && hideDisabled) {
continue;
}
final childChatSource = await _recursiveBuild(childView, view);
if (childChatSource.selectedStatus.isSelected) {
final childItem = await _recursiveBuild(childView, view);
if (childItem.selectedStatus.isSelected) {
selectedCount++;
}
children.add(childChatSource);
children.add(childItem);
}
final areAllChildrenSelectedOrNoChildren =
@@ -186,47 +197,64 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
selectedStatus = ViewSelectedStatus.partiallySelected;
}
} else if (isThisSourceSelected) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
}
return ChatSource(
return ViewSelectorItem(
view: view,
parentView: parentView,
children: children,
ignoreStatus: view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable,
isDisabled: false,
isExpanded: false,
selectedStatus: selectedStatus,
);
}
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
void _setIsDisabledAndHideIfNecessary(
List<ViewSelectorItem> sources,
) {
sources.retainWhere((source) {
final ignoreViewType = getIgnoreViewType(source);
return ignoreViewType != IgnoreViewType.hide;
});
for (final source in sources) {
source.resetIgnoreViewTypeRecursive();
source.isDisabledNotifier.value =
getIgnoreViewType(source) == IgnoreViewType.disable;
_setIsDisabledAndHideIfNecessary(source.children);
}
}
void _restrictSelectionIfNecessary(List<ViewSelectorItem> sources) {
if (maxSelectedParentPageCount == null) {
return;
}
for (final source in sources) {
source.setIsDisabledRecursive((view) {
return getIgnoreViewType(view) == IgnoreViewType.disable;
});
}
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
_kMaxSelectedParentPageCount) {
maxSelectedParentPageCount!) {
sources
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
.where((e) => e.selectedStatus == ViewSelectedStatus.unselected)
.forEach(
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
(e) => e.setIsDisabledRecursive((_) => true),
);
}
}
void updateFilter(String filter) {
this.filter = filter;
void onFilterChanged() {
for (final source in state.visibleSources) {
source.dispose();
}
if (sources.isEmpty) {
emit(ChatSettingsState.initial());
emit(ViewSelectorState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
@@ -242,13 +270,13 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
/// traverse tree to build up search query
ChatSource? _buildSearchResults(ChatSource chatSource) {
final isVisible = chatSource.view.nameOrDefault
ViewSelectorItem? _buildSearchResults(ViewSelectorItem item) {
final isVisible = item.view.nameOrDefault
.toLowerCase()
.contains(filter.toLowerCase());
.contains(filterTextController.text.toLowerCase());
final childrenResults = <ChatSource>[];
for (final childSource in chatSource.children) {
final childrenResults = <ViewSelectorItem>[];
for (final childSource in item.children) {
final childResult = _buildSearchResults(childSource);
if (childResult != null) {
childrenResults.add(childResult);
@@ -256,48 +284,50 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
return isVisible || childrenResults.isNotEmpty
? ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
? ViewSelectorItem(
view: item.view,
parentView: item.parentView,
children: childrenResults,
ignoreStatus: chatSource.ignoreStatus,
isExpanded: chatSource.isExpanded,
selectedStatus: chatSource.selectedStatus,
isDisabled: item.isDisabled,
isExpanded: item.isExpanded,
selectedStatus: item.selectedStatus,
)
: null;
}
/// traverse tree to build up selected sources
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
final children = <ChatSource>[];
Iterable<ViewSelectorItem> _buildSelectedSources(
ViewSelectorItem item,
) {
final children = <ViewSelectorItem>[];
for (final childSource in chatSource.children) {
for (final childSource in item.children) {
children.addAll(_buildSelectedSources(childSource));
}
return selectedSourceIds.contains(chatSource.view.id)
return selectedSourceIds.contains(item.view.id)
? [
ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
ViewSelectorItem(
view: item.view,
parentView: item.parentView,
children: children,
ignoreStatus: chatSource.ignoreStatus,
selectedStatus: chatSource.selectedStatus,
isDisabled: item.isDisabled,
selectedStatus: item.selectedStatus,
isExpanded: true,
),
]
: children;
}
void toggleSelectedStatus(ChatSource chatSource) {
if (chatSource.view.isSpace) {
void toggleSelectedStatus(ViewSelectorItem item, bool isSelectedSection) {
if (item.view.isSpace) {
return;
}
final allIds = _recursiveGetSourceIds(chatSource);
final allIds = _recursiveGetSourceIds(item);
if (chatSource.selectedStatus.isUnselected ||
chatSource.selectedStatus.isPartiallySelected &&
!chatSource.view.layout.isDocumentView) {
if (item.selectedStatus.isUnselected ||
item.selectedStatus.isPartiallySelected &&
!item.view.layout.isDocumentView) {
for (final id in allIds) {
if (!selectedSourceIds.contains(id)) {
selectedSourceIds.add(id);
@@ -311,13 +341,22 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
}
if (isSelectedSection) {
item.setIsSelectedStatusRecursive(
item.selectedStatus.isUnselected ||
item.selectedStatus.isPartiallySelected
? ViewSelectedStatus.selected
: ViewSelectedStatus.unselected,
);
}
updateSelectedStatus();
}
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
List<String> _recursiveGetSourceIds(ViewSelectorItem item) {
return [
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
for (final childSource in chatSource.children)
if (item.view.layout.isDocumentView) item.view.id,
for (final childSource in item.children)
..._recursiveGetSourceIds(childSource),
];
}
@@ -342,44 +381,42 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
);
}
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
ViewSelectedStatus _recursiveUpdateSelectedStatus(ViewSelectorItem item) {
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
int selectedCount = 0;
for (final childSource in chatSource.children) {
for (final childSource in item.children) {
final childStatus = _recursiveUpdateSelectedStatus(childSource);
if (childStatus.isSelected) {
selectedCount++;
}
}
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
final isThisSourceSelected = selectedSourceIds.contains(item.view.id);
final areAllChildrenSelectedOrNoChildren =
chatSource.children.length == selectedCount;
item.children.length == selectedCount;
final isAnyChildNotUnselected =
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
item.children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
selectedStatus = ViewSelectedStatus.partiallySelected;
}
chatSource.selectedStatusNotifier.value = selectedStatus;
item.selectedStatusNotifier.value = selectedStatus;
return selectedStatus;
}
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
chatSource.toggleIsExpanded();
void toggleIsExpanded(ViewSelectorItem item, bool isSelectedSection) {
item.toggleIsExpanded();
if (isSelectedSection) {
for (final selectedSource in selectedSources) {
selectedSource
.findChildBySourceId(chatSource.view.id)
?.toggleIsExpanded();
selectedSource.findChildBySourceId(item.view.id)?.toggleIsExpanded();
}
} else {
for (final source in sources) {
final child = source.findChildBySourceId(chatSource.view.id);
final child = source.findChildBySourceId(item.view.id);
if (child != null) {
child.toggleIsExpanded();
break;
@@ -402,18 +439,19 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
for (final child in state.visibleSources) {
child.dispose();
}
filterTextController.dispose();
return super.close();
}
}
@freezed
class ChatSettingsState with _$ChatSettingsState {
const factory ChatSettingsState({
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
class ViewSelectorState with _$ViewSelectorState {
const factory ViewSelectorState({
required List<ViewSelectorItem> visibleSources,
required List<ViewSelectorItem> selectedSources,
}) = _ViewSelectorState;
factory ChatSettingsState.initial() => const ChatSettingsState(
factory ViewSelectorState.initial() => const ViewSelectorState(
visibleSources: [],
selectedSources: [],
);
@@ -8,11 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class AiPromptCategoryList extends StatefulWidget {
const AiPromptCategoryList({
super.key,
required this.padding,
});
final EdgeInsetsGeometry padding;
@override
State<AiPromptCategoryList> createState() => _AiPromptCategoryListState();
}
@@ -23,60 +20,59 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: EdgeInsets.all(
theme.spacing.l,
),
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: theme.spacing.s,
children: [
_buildFeaturedCategory(context),
const AFDivider(),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_buildCategoryItem(context, null),
_buildCategoryItem(context, AiPromptCategory.development),
_buildCategoryItem(context, AiPromptCategory.writing),
_buildCategoryItem(context, AiPromptCategory.business),
_buildCategoryItem(context, AiPromptCategory.marketing),
_buildCategoryItem(
context,
AiPromptCategory.healthAndFitness,
),
_buildCategoryItem(context, AiPromptCategory.travel),
_buildCategoryItem(context, AiPromptCategory.contentSeo),
_buildCategoryItem(context, AiPromptCategory.emailMarketing),
_buildCategoryItem(context, AiPromptCategory.paidAds),
_buildCategoryItem(context, AiPromptCategory.prCommunication),
_buildCategoryItem(context, AiPromptCategory.recruiting),
_buildCategoryItem(context, AiPromptCategory.sales),
_buildCategoryItem(context, AiPromptCategory.socialMedia),
_buildCategoryItem(context, AiPromptCategory.strategy),
_buildCategoryItem(context, AiPromptCategory.caseStudies),
_buildCategoryItem(context, AiPromptCategory.salesCopy),
_buildCategoryItem(context, AiPromptCategory.education),
_buildCategoryItem(context, AiPromptCategory.work),
_buildCategoryItem(
context,
AiPromptCategory.podcastProduction,
),
_buildCategoryItem(context, AiPromptCategory.copyWriting),
_buildCategoryItem(context, AiPromptCategory.customerSuccess),
_buildCategoryItem(context, AiPromptCategory.other),
],
),
return TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.only(
right: theme.spacing.l,
),
],
),
child: AiPromptFeaturedSection(),
),
Padding(
padding: EdgeInsets.only(
right: theme.spacing.l,
),
child: AiPromptCustomPromptSection(),
),
Padding(
padding: EdgeInsets.only(
top: theme.spacing.s,
right: theme.spacing.l,
),
child: AFDivider(),
),
Expanded(
child: ListView(
padding: EdgeInsets.only(
top: theme.spacing.s,
right: theme.spacing.l,
),
children: [
_buildCategoryItem(context, null),
...sortedCategories.map(
(category) => _buildCategoryItem(
context,
category,
),
),
],
),
),
],
),
);
}
static Iterable<AiPromptCategory> get sortedCategories {
final categories = [...AiPromptCategory.values];
categories.sort((a, b) => a.i18n.compareTo(b.i18n));
return categories;
}
Widget _buildCategoryItem(
BuildContext context,
AiPromptCategory? category,
@@ -88,18 +84,25 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
},
);
}
}
Widget _buildFeaturedCategory(BuildContext context) {
class AiPromptFeaturedSection extends StatelessWidget {
const AiPromptFeaturedSection({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) => state.isFeaturedCategorySelected,
ready: (state) => state.isFeaturedSectionSelected,
orElse: () => false,
);
return AFBaseButton(
onTap: () {
if (!isSelected) {
context.read<AiPromptSelectorCubit>().selectFeaturedCategory();
context.read<AiPromptSelectorCubit>().selectFeaturedSection();
}
},
builder: (context, isHovering, disabled) {
@@ -131,6 +134,61 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
}
}
class AiPromptCustomPromptSection extends StatelessWidget {
const AiPromptCustomPromptSection({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
final isSelected = readyState.isCustomPromptSectionSelected;
return AFBaseButton(
onTap: () {
if (!isSelected) {
context.read<AiPromptSelectorCubit>().selectCustomSection();
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_custom.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return Colors.transparent;
},
);
},
orElse: () => const SizedBox.shrink(),
);
},
);
}
}
class AiPromptCategoryItem extends StatelessWidget {
const AiPromptCategoryItem({
super.key,
@@ -143,41 +201,47 @@ class AiPromptCategoryItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) =>
!state.isFeaturedCategorySelected &&
state.selectedCategory == category,
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
final isSelected = state.maybeMap(
ready: (state) {
return !state.isFeaturedSectionSelected &&
!state.isCustomPromptSectionSelected &&
state.selectedCategory == category;
},
orElse: () => false,
);
return AFBaseButton(
onTap: onSelect,
builder: (context, isHovering, disabled) {
return Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
return AFBaseButton(
onTap: onSelect,
builder: (context, isHovering, disabled) {
return Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return Colors.transparent;
},
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return Colors.transparent;
},
);
}
}
@@ -0,0 +1,346 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.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';
import 'package:flutter_bloc/flutter_bloc.dart';
class CustomPromptDatabaseSelector extends StatefulWidget {
const CustomPromptDatabaseSelector({
super.key,
this.databaseViewId,
this.isLoading = false,
this.popoverDirection = PopoverDirection.bottomWithCenterAligned,
required this.childBuilder,
});
final String? databaseViewId;
final bool isLoading;
final PopoverDirection popoverDirection;
final Widget Function(VoidCallback) childBuilder;
@override
State<CustomPromptDatabaseSelector> createState() =>
_CustomPromptDatabaseSelectorState();
}
class _CustomPromptDatabaseSelectorState
extends State<CustomPromptDatabaseSelector> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: getIgnoreViewType,
),
),
child: BlocSelector<SpaceBloc, SpaceState, (List<ViewPB>, ViewPB?)>(
selector: (state) => (state.spaces, state.currentSpace),
builder: (context, state) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(0, 4.0),
direction: widget.popoverDirection,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ViewSelectorCubit>(),
child: _PopoverContent(
onSelectViewItem: (item) {
final view = item.view;
if (view.layout.isDatabaseView) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseViewId(view.id);
popoverController.close();
} else if (!view.isSpace && view.layout.isDocumentView) {
context.read<ViewSelectorCubit>().toggleIsExpanded(
item,
false,
);
}
},
),
);
},
child: widget.childBuilder(
() {
if (!widget.isLoading) {
context
.read<ViewSelectorCubit>()
.refreshSources(state.$1, state.$2);
popoverController.show();
}
},
),
);
},
),
);
}
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
final layout = item.view.layout;
if (layout.isDatabaseView) {
return IgnoreViewType.none;
}
if (layout.isDocumentView) {
return hasDatabaseDescendent(item)
? IgnoreViewType.none
: IgnoreViewType.hide;
}
return IgnoreViewType.hide;
}
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
final layout = viewSelectorItem.view.layout;
if (layout == ViewLayoutPB.Chat) {
return false;
}
if (layout.isDatabaseView) {
return true;
}
// document may have children
return viewSelectorItem.children.any(
(child) => hasDatabaseDescendent(child),
);
}
}
class AiPromptDatabaseSelectorButton extends StatefulWidget {
const AiPromptDatabaseSelectorButton({
super.key,
required this.selectedViewId,
required this.isLoading,
required this.onTap,
});
final String? selectedViewId;
final bool isLoading;
final VoidCallback onTap;
@override
State<AiPromptDatabaseSelectorButton> createState() =>
_AiPromptDatabaseSelectorButtonState();
}
class _AiPromptDatabaseSelectorButtonState
extends State<AiPromptDatabaseSelectorButton> {
late Future<ViewPB?> viewFuture;
@override
void initState() {
super.initState();
viewFuture = getView();
}
@override
void didUpdateWidget(covariant AiPromptDatabaseSelectorButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedViewId != widget.selectedViewId) {
viewFuture = getView();
}
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder<ViewPB?>(
future: viewFuture,
builder: (context, snapshot) {
String name = "";
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
name = snapshot.data!.nameOrDefault;
}
return Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: $name",
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
),
child: AFOutlinedButton.normal(
onTap: widget.onTap,
builder: (context, isHovering, disabled) {
return Row(
spacing: theme.spacing.s,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isLoading) buildLoadingIndicator(theme),
Flexible(
child: Text(
widget.isLoading
? LocaleKeys.ai_customPrompt_loading.tr()
: LocaleKeys.button_change.tr(),
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
},
),
),
],
);
},
);
}
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
return SizedBox.square(
dimension: 20,
child: Padding(
padding: EdgeInsets.all(2.5),
child: CircularProgressIndicator(
color: theme.iconColorScheme.tertiary,
strokeWidth: 2.0,
),
),
);
}
Future<ViewPB?> getView() async {
if (widget.selectedViewId == null) {
return null;
}
final view =
await ViewBackendService.getView(widget.selectedViewId!).toNullable();
if (view != null) {
return view;
}
final trashViews = await TrashService().readTrash().toNullable();
final trashedItem = trashViews?.items
.firstWhereOrNull((element) => element.id == widget.selectedViewId);
if (trashedItem == null) {
return null;
}
return ViewPB()
..id = trashedItem.id
..name = trashedItem.name;
}
}
class _PopoverContent extends StatefulWidget {
const _PopoverContent({
required this.onSelectViewItem,
});
final void Function(ViewSelectorItem item) onSelectViewItem;
@override
State<_PopoverContent> createState() => _PopoverContentState();
}
class _PopoverContentState extends State<_PopoverContent> {
final focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(theme.spacing.m),
Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
),
child: AFTextField(
focusNode: focusNode,
size: AFTextFieldSize.m,
hintText: LocaleKeys.search_label.tr(),
controller: context.read<ViewSelectorCubit>().filterTextController,
),
),
VSpace(theme.spacing.m),
AFDivider(),
Expanded(
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
);
},
),
),
],
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ViewSelectorState state,
) {
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'custom_prompt_database_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: widget.onSelectViewItem,
height: 30.0,
),
);
}
}
@@ -1,6 +1,8 @@
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/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -8,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_category_list.dart';
import 'ai_prompt_onboarding.dart';
import 'ai_prompt_preview.dart';
import 'ai_prompt_visible_list.dart';
@@ -15,11 +18,20 @@ Future<AiPrompt?> showAiPromptModal(
BuildContext context, {
required AiPromptSelectorCubit aiPromptSelectorCubit,
}) async {
aiPromptSelectorCubit.loadCustomPrompts();
return showDialog<AiPrompt?>(
context: context,
builder: (context) {
return BlocProvider.value(
value: aiPromptSelectorCubit,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: aiPromptSelectorCubit,
),
BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
),
],
child: const AiPromptModal(),
);
},
@@ -38,92 +50,122 @@ class AiPromptModal extends StatelessWidget {
maxWidth: 1200,
maxHeight: 800,
),
child: Column(
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
child: BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
invalidDatabase: (_) {
showLoadPromptFailedDialog(context);
},
orElse: () {},
);
},
child: Column(
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.xs),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.s),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
Expanded(
child: AFModalBody(
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return state.map(
loading: (_) {
return const Center(
child: CircularProgressIndicator(),
);
},
ready: (_) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: AiPromptCategoryList(
padding: EdgeInsetsDirectional.only(
top: theme.spacing.l,
end: theme.spacing.l,
),
Expanded(
child: AFModalBody(
child:
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
loading: (_) {
return const Center(
child: CircularProgressIndicator(),
);
},
ready: (readyState) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: AiPromptCategoryList(),
),
),
Expanded(
flex: 2,
child: AiPromptVisibleList(),
),
Expanded(
flex: 3,
child: BlocBuilder<AiPromptSelectorCubit,
AiPromptSelectorState>(
builder: (context, state) {
final selectedPrompt = state.maybeMap(
ready: (state) {
return state.visiblePrompts
.firstWhereOrNull(
(prompt) =>
prompt.id == state.selectedPromptId,
if (readyState.isCustomPromptSectionSelected &&
readyState.customPromptDatabaseViewId == null)
const Expanded(
flex: 5,
child: Center(
child: AiPromptOnboarding(),
),
)
else ...[
const Expanded(
flex: 2,
child: AiPromptVisibleList(),
),
Expanded(
flex: 3,
child: BlocBuilder<AiPromptSelectorCubit,
AiPromptSelectorState>(
builder: (context, state) {
final selectedPrompt = state.maybeMap(
ready: (state) {
return state.visiblePrompts
.firstWhereOrNull(
(prompt) =>
prompt.id ==
state.selectedPromptId,
);
},
orElse: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
);
},
orElse: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
);
},
),
),
],
);
},
);
},
),
),
],
],
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
),
),
],
],
),
),
);
}
}
void showLoadPromptFailedDialog(
BuildContext context,
) {
showSimpleAFDialog(
context: context,
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
primaryAction: (
LocaleKeys.button_ok.tr(),
(context) {},
),
);
}
@@ -0,0 +1,53 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/widgets.dart';
import 'ai_prompt_database_selector.dart';
class AiPromptOnboarding extends StatelessWidget {
const AiPromptOnboarding({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
LocaleKeys.ai_customPrompt_customPrompt.tr(),
style: theme.textStyle.heading3.standard(
color: theme.textColorScheme.primary,
),
),
VSpace(
theme.spacing.s,
),
Text(
LocaleKeys.ai_customPrompt_databasePrompts.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
VSpace(
theme.spacing.xxl,
),
CustomPromptDatabaseSelector(
childBuilder: (onTap) => AFFilledButton.primary(
onTap: onTap,
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
),
],
);
}
}
@@ -22,10 +22,8 @@ class AiPromptPreview extends StatelessWidget {
child: Column(
children: [
Padding(
padding: EdgeInsets.only(
top: theme.spacing.l,
left: theme.spacing.l,
right: theme.spacing.l,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.l,
),
child: SelectionContainer.disabled(
child: Row(
@@ -7,10 +7,12 @@ import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:diffutil_dart/diffutil.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_selector.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
class AiPromptVisibleList extends StatefulWidget {
@@ -35,12 +37,12 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
super.initState();
cubit = context.read<AiPromptSelectorCubit>();
final textController = cubit.filterTextController;
filterIsEmpty = textController.text.isEmpty;
textController.addListener(handleFilterTextChanged);
final prompts = cubit.state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
filterIsEmpty = textController.text.isEmpty;
textController.addListener(handleFilterTextChanged);
oldList.addAll(prompts);
}
@@ -53,52 +55,87 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
@override
Widget build(BuildContext context) {
final spacing = AppFlowyTheme.of(context).spacing;
final theme = AppFlowyTheme.of(context);
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
final list = state.maybeMap(
ready: (state) => state.visiblePrompts,
orElse: () => <AiPrompt>[],
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
},
orElse: () {},
);
handleVisiblePromptListChanged(list);
},
child: Column(
children: [
VSpace(spacing.l),
Padding(
padding: EdgeInsets.symmetric(horizontal: spacing.l),
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
VSpace(spacing.s),
VSpace(
theme.spacing.s,
),
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
buildWhen: (p, c) {
return p.maybeMap(
ready: (pr) => c.maybeMap(
ready: (cr) =>
pr.customPromptDatabaseViewId !=
cr.customPromptDatabaseViewId ||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
pr.isCustomPromptSectionSelected !=
cr.isCustomPromptSectionSelected,
orElse: () => false,
),
orElse: () => true,
);
},
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (!readyState.isCustomPromptSectionSelected) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(
left: theme.spacing.l,
top: theme.spacing.s,
right: theme.spacing.l,
),
child: CustomPromptDatabaseSelector(
databaseViewId: readyState.customPromptDatabaseViewId,
isLoading: readyState.isLoadingCustomPrompts,
popoverDirection: PopoverDirection.bottomWithRightAligned,
childBuilder: (onTap) {
return AiPromptDatabaseSelectorButton(
selectedViewId: readyState.customPromptDatabaseViewId,
isLoading: readyState.isLoadingCustomPrompts,
onTap: onTap,
);
},
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
Expanded(
child: AnimatedList(
controller: scrollController,
padding: EdgeInsets.all(spacing.l),
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
final cubit = context.read<AiPromptSelectorCubit>();
return cubit.state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : spacing.s,
bottom: index == state.visiblePrompts.length - 1
? 0
: spacing.s,
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (readyState.visiblePrompts.isEmpty) {
return buildEmptyPrompts();
}
return buildPromptList();
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
),
],
@@ -141,6 +178,68 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
);
}
Widget buildEmptyPrompts() {
final theme = AppFlowyTheme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
VSpace(theme.spacing.m),
Text(
LocaleKeys.ai_customPrompt_noResults.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget buildPromptList() {
final theme = AppFlowyTheme.of(context);
return AnimatedList(
controller: scrollController,
padding: EdgeInsets.all(theme.spacing.l),
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : theme.spacing.s,
bottom: index == state.visiblePrompts.length - 1
? 0
: theme.spacing.s,
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
);
},
);
}
void handleVisiblePromptListChanged(
List<AiPrompt> newList,
) {
@@ -258,6 +357,7 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
color: Colors.transparent,
border: Border.all(
color: widget.isSelected
? isHovering
@@ -267,7 +367,6 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1,8 +1,10 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_user_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/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
@@ -47,6 +49,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final chatUserCubit = ChatUserCubit();
final focusNode = FocusNode();
late SendButtonState sendButtonState;
@@ -74,6 +77,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
checkForAskingAI();
});
}
@@ -88,13 +92,17 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
focusNode.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
chatUserCubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: inputControlCubit,
return MultiBlocProvider(
providers: [
BlocProvider.value(value: inputControlCubit),
BlocProvider.value(value: chatUserCubit),
],
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
@@ -221,6 +229,26 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
);
}
void checkForAskingAI() {
final paletteBloc = context.read<CommandPaletteBloc?>(),
paletteState = paletteBloc?.state;
if (paletteBloc == null || paletteState == null) return;
final isAskingAI = paletteState.askAI;
if (!isAskingAI) return;
paletteBloc.add(CommandPaletteEvent.askedAI());
final query = paletteState.query ?? '';
if (query.isEmpty) return;
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
final metadata =
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
final promptState = context.read<AIPromptInputBloc?>()?.state;
final predefinedFormat = promptState?.predefinedFormat;
if (sources.isNotEmpty) {
widget.onUpdateSelectedSources(sources);
}
widget.onSubmitted.call(query, predefinedFormat, metadata);
}
void startMentionPageFromButton() {
if (overlayController.isShowing) {
return;
@@ -634,7 +662,9 @@ class _PromptBottomActions extends StatelessWidget {
const Spacer(),
if (state.modelState.type == AiType.cloud) _selectSourcesButton(),
if (context.read<ChatUserCubit>().supportSelectSource())
_selectSourcesButton(),
if (extraBottomActionButton != null) extraBottomActionButton!,
// _mentionButton(context),
if (state.supportChatWithFile) _attachmentButton(context),
@@ -1,13 +1,16 @@
import 'dart:async';
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_select_sources_cubit.dart';
import 'package:appflowy/ai/service/view_selector_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.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';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -32,7 +35,18 @@ class PromptInputMobileSelectSourcesButton extends StatefulWidget {
class _PromptInputMobileSelectSourcesButtonState
extends State<PromptInputMobileSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
late final cubit = ViewSelectorCubit(
maxSelectedParentPageCount: 3,
getIgnoreViewType: (item) {
if (item.view.isSpace) {
return IgnoreViewType.none;
}
if (item.view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
);
@override
void initState() {
@@ -90,9 +104,12 @@ class _PromptInputMobileSelectSourcesButtonState
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace),
);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
@@ -129,32 +146,17 @@ class _PromptInputMobileSelectSourcesButtonState
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
class _MobileSelectSourcesSheetBody extends StatelessWidget {
const _MobileSelectSourcesSheetBody({
required this.scrollController,
});
final ScrollController scrollController;
@override
State<_MobileSelectSourcesSheetBody> createState() =>
_MobileSelectSourcesSheetBodyState();
}
class _MobileSelectSourcesSheetBodyState
extends State<_MobileSelectSourcesSheetBody> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
controller: scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
@@ -183,10 +185,9 @@ class _MobileSelectSourcesSheetBodyState
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (value) => context
.read<ChatSettingsCubit>()
.updateFilter(value),
controller: context
.read<ViewSelectorCubit>()
.filterTextController,
),
),
),
@@ -196,27 +197,25 @@ class _MobileSelectSourcesSheetBodyState
),
),
),
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final sources = state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: sources.length,
childCount: state.visibleSources.length,
(context, index) {
final source = sources.elementAt(index);
return ChatSourceTreeItem(
final source = state.visibleSources.elementAt(index);
return ViewSelectorTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${source.view.id}',
),
chatSource: source,
viewSelectorItem: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
onSelected: (item) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
.read<ViewSelectorCubit>()
.toggleSelectedStatus(item, false);
},
height: 40.0,
);
@@ -2,15 +2,13 @@ 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_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';
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_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -18,6 +16,8 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../service/view_selector_cubit.dart';
import '../view_selector.dart';
import 'layout_define.dart';
import 'mention_page_menu.dart';
@@ -38,7 +38,24 @@ class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
class _PromptInputDesktopSelectSourcesButtonState
extends State<PromptInputDesktopSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
late final cubit = ViewSelectorCubit(
maxSelectedParentPageCount: 3,
getIgnoreViewType: (item) {
final view = item.view;
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout == ViewLayoutPB.Chat) {
return IgnoreViewType.hide;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.disable;
}
return IgnoreViewType.none;
},
);
final popoverController = PopoverController();
@override
@@ -59,23 +76,10 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
return ViewSelector(
viewSelectorCubit: BlocProvider.value(
value: cubit,
),
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return AppFlowyPopover(
@@ -86,18 +90,18 @@ class _PromptInputDesktopSelectSourcesButtonState
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: const _PopoverContent(),
);
},
@@ -186,20 +190,26 @@ class _PopoverContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
child: AFTextField(
size: AFTextFieldSize.m,
controller:
context.read<ViewSelectorCubit>().filterTextController,
hintText: LocaleKeys.search_label.tr(),
),
),
_buildDivider(),
AFDivider(
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
Flexible(
child: ListView(
shrinkWrap: true,
@@ -208,9 +218,10 @@ class _PopoverContent extends StatelessWidget {
..._buildSelectedSources(context, state),
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: _buildDivider(),
AFDivider(
spacing: 4.0,
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
..._buildVisibleSources(context, state),
],
@@ -222,70 +233,53 @@ class _PopoverContent extends StatelessWidget {
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 8.0,
endIndent: 8.0,
);
}
Iterable<Widget> _buildSelectedSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState state,
) {
return state.selectedSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
return state.selectedSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (item) {
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, true);
},
height: 30.0,
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (item) {
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, false);
},
height: 30.0,
),
);
}
}
class ChatSourceTreeItem extends StatefulWidget {
const ChatSourceTreeItem({
class ViewSelectorTreeItem extends StatefulWidget {
const ViewSelectorTreeItem({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
@@ -296,7 +290,7 @@ class ChatSourceTreeItem extends StatefulWidget {
this.showCheckbox = true,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
/// nested level of the view item
final int level;
@@ -305,9 +299,9 @@ class ChatSourceTreeItem extends StatefulWidget {
final bool isSelectedSection;
final void Function(ChatSource chatSource) onSelected;
final void Function(ViewSelectorItem viewSelectorItem) onSelected;
final void Function(ChatSource chatSource)? onAdd;
final void Function(ViewSelectorItem viewSelectorItem)? onAdd;
final bool showSaveButton;
@@ -316,16 +310,16 @@ class ChatSourceTreeItem extends StatefulWidget {
final bool showCheckbox;
@override
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
State<ViewSelectorTreeItem> createState() => _ViewSelectorTreeItemState();
}
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: widget.height,
child: ChatSourceTreeItemInner(
chatSource: widget.chatSource,
child: ViewSelectorTreeItemInner(
viewSelectorItem: widget.viewSelectorItem,
level: widget.level,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
@@ -336,33 +330,30 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
),
);
final disabledEnabledChild =
widget.chatSource.ignoreStatus == IgnoreViewType.disable
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.chatSource.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
final disabledEnabledChild = widget.viewSelectorItem.isDisabled
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.viewSelectorItem.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
return ValueListenableBuilder(
valueListenable: widget.chatSource.isExpandedNotifier,
valueListenable: widget.viewSelectorItem.isExpandedNotifier,
builder: (context, isExpanded, child) {
// filter the child views that should be ignored
final childViews = widget.chatSource.children
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.toList();
final childViews = widget.viewSelectorItem.children;
if (!isExpanded || childViews.isEmpty) {
return disabledEnabledChild;
@@ -373,11 +364,11 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
children: [
disabledEnabledChild,
...childViews.map(
(childSource) => ChatSourceTreeItem(
(childSource) => ViewSelectorTreeItem(
key: ValueKey(
'select_sources_tree_item_${childSource.view.id}',
),
chatSource: childSource,
viewSelectorItem: childSource,
level: widget.level + 1,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
@@ -395,10 +386,10 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
}
}
class ChatSourceTreeItemInner extends StatelessWidget {
const ChatSourceTreeItemInner({
class ViewSelectorTreeItemInner extends StatelessWidget {
const ViewSelectorTreeItemInner({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
@@ -408,34 +399,27 @@ class ChatSourceTreeItemInner extends StatelessWidget {
this.onAdd,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final bool showCheckbox;
final bool showSaveButton;
final void Function(ChatSource)? onSelected;
final void Function(ChatSource)? onAdd;
final void Function(ViewSelectorItem)? onSelected;
final void Function(ViewSelectorItem)? onAdd;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (!isSelectedSection) {
onSelected?.call(chatSource);
}
},
onTap: () => onSelected?.call(viewSelectorItem),
child: FlowyHover(
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
style: HoverStyle(
hoverColor: isSelectedSection
? Colors.transparent
: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
builder: (context, onHover) {
final isSaveButtonVisible =
showSaveButton && !chatSource.view.isSpace;
showSaveButton && !viewSelectorItem.view.isSpace;
final isAddButtonVisible = onAdd != null;
return Row(
children: [
@@ -443,26 +427,26 @@ class ChatSourceTreeItemInner extends StatelessWidget {
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
// builds the >, ^ or · button
ToggleIsExpandedButton(
chatSource: chatSource,
viewSelectorItem: viewSelectorItem,
isSelectedSection: isSelectedSection,
),
const HSpace(2.0),
// checkbox
if (!chatSource.view.isSpace && showCheckbox) ...[
if (!viewSelectorItem.view.isSpace && showCheckbox) ...[
SourceSelectedStatusCheckbox(
chatSource: chatSource,
viewSelectorItem: viewSelectorItem,
),
const HSpace(4.0),
],
// icon
MentionViewIcon(
view: chatSource.view,
view: viewSelectorItem.view,
),
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
chatSource.view.nameOrDefault,
viewSelectorItem.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
@@ -479,7 +463,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onPressed: () => onSelected?.call(chatSource),
onPressed: () => onSelected?.call(viewSelectorItem),
),
if (isSaveButtonVisible && isAddButtonVisible)
const HSpace(4.0),
@@ -492,7 +476,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onPressed: () => onAdd?.call(chatSource),
onPressed: () => onAdd?.call(viewSelectorItem),
),
const HSpace(4.0),
],
@@ -507,27 +491,30 @@ class ChatSourceTreeItemInner extends StatelessWidget {
class ToggleIsExpandedButton extends StatelessWidget {
const ToggleIsExpandedButton({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.isSelectedSection,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
final bool isSelectedSection;
@override
Widget build(BuildContext context) {
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
if (isReferencedDatabaseView(
viewSelectorItem.view,
viewSelectorItem.parentView,
)) {
return const _DotIconWidget();
}
if (chatSource.children.isEmpty) {
if (viewSelectorItem.children.isEmpty) {
return const SizedBox.square(dimension: 16.0);
}
return FlowyHover(
child: GestureDetector(
child: ValueListenableBuilder(
valueListenable: chatSource.isExpandedNotifier,
valueListenable: viewSelectorItem.isExpandedNotifier,
builder: (context, value, _) => FlowySvg(
value
? FlowySvgs.view_item_expand_s
@@ -536,8 +523,8 @@ class ToggleIsExpandedButton extends StatelessWidget {
),
),
onTap: () => context
.read<ChatSettingsCubit>()
.toggleIsExpanded(chatSource, isSelectedSection),
.read<ViewSelectorCubit>()
.toggleIsExpanded(viewSelectorItem, isSelectedSection),
),
);
}
@@ -565,20 +552,20 @@ class _DotIconWidget extends StatelessWidget {
class SourceSelectedStatusCheckbox extends StatelessWidget {
const SourceSelectedStatusCheckbox({
super.key,
required this.chatSource,
required this.viewSelectorItem,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chatSource.selectedStatusNotifier,
valueListenable: viewSelectorItem.selectedStatusNotifier,
builder: (context, selectedStatus, _) => FlowySvg(
switch (selectedStatus) {
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
ViewSelectedStatus.unselected => FlowySvgs.uncheck_s,
ViewSelectedStatus.selected => FlowySvgs.check_filled_s,
ViewSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
},
size: const Size.square(18.0),
blendMode: null,
@@ -0,0 +1,39 @@
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/single_child_widget.dart';
class ViewSelector extends StatelessWidget {
const ViewSelector({
super.key,
required this.viewSelectorCubit,
required this.child,
});
final SingleChildWidget viewSelectorCubit;
final Widget child;
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) {
return SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false));
},
),
viewSelectorCubit,
],
child: child,
);
}
}
@@ -27,7 +27,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:sentry/sentry.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({super.key});
@@ -65,14 +64,6 @@ class MobileHomeScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Scaffold(
body: SafeArea(
bottom: false,
@@ -19,7 +19,7 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
onTap: () {
context
.read<CommandPaletteBloc?>()
?.add(CommandPaletteEvent.gointToAskAI());
?.add(CommandPaletteEvent.goingToAskAI());
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
@@ -10,7 +10,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'mobile_search_ask_ai_entrance.dart';
import 'mobile_search_result.dart';
@@ -54,13 +53,6 @@ class MobileSearchScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Provider.value(
value: userProfile,
child: MobileSearchPage(
@@ -150,10 +150,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
child: FlowySvg(
FlowySvgs.clear_s,
blendMode: null,
color: theme.iconColorScheme.tertiary,
size: Size.square(20),
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.secondary,
size: const Size.square(20),
),
),
)
@@ -1,4 +1,7 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -18,6 +21,8 @@ class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
(event, emit) async {
await event.map(
initial: (e) async {
emit(state.copyWith(isLoading: true));
final ancester = await _cache.getAncestor(
state.viewId,
onRefresh: (ancestor) {
@@ -53,3 +58,46 @@ class ViewAncestorState with _$ViewAncestorState {
ancestor: ViewAncestor.empty(),
);
}
extension ViewAncestorTextExtension on ViewAncestorState {
Widget buildPath(BuildContext context, {TextStyle? style}) {
final theme = AppFlowyTheme.of(context);
final ancestors = ancestor.ancestors;
final textStyle = style ??
theme.textStyle.caption.standard(color: theme.textColorScheme.tertiary);
final textHeight = (textStyle.fontSize ?? 0.0) * (textStyle.height ?? 1.0);
if (isLoading) return VSpace(textHeight);
return LayoutBuilder(
builder: (context, constrains) {
final List<String> displayPath = ancestors.map((e) => e.name).toList();
if (displayPath.isEmpty) return const SizedBox.shrink();
TextPainter textPainter =
_buildTextPainter(displayPath.join(' / '), textStyle);
textPainter.layout(maxWidth: constrains.maxWidth);
if (textPainter.didExceedMaxLines && displayPath.length > 2) {
displayPath.removeAt(displayPath.length - 2);
displayPath.insert(displayPath.length - 1, '...');
}
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
textPainter.layout(maxWidth: constrains.maxWidth);
while (textPainter.didExceedMaxLines && displayPath.length > 3) {
displayPath.removeAt(displayPath.length - 2);
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
textPainter.layout(maxWidth: constrains.maxWidth);
}
return Text(
displayPath.join(' / '),
style: textStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
);
}
TextPainter _buildTextPainter(String text, TextStyle style) => TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
);
}
@@ -0,0 +1,14 @@
export 'ai_model_switch_listener.dart';
export 'chat_ai_message_bloc.dart';
export 'chat_bloc.dart';
export 'chat_edit_document_service.dart';
export 'chat_entity.dart';
export 'chat_input_control_cubit.dart';
export 'chat_input_file_bloc.dart';
export 'chat_member_bloc.dart';
export 'chat_message_listener.dart';
export 'chat_message_service.dart';
export 'chat_message_stream.dart';
export 'chat_notification.dart';
export 'chat_select_message_bloc.dart';
export 'chat_user_message_bloc.dart';
@@ -117,6 +117,14 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
),
);
});
on<_OnAIFollowUp>((event, emit) {
emit(
state.copyWith(
messageState: MessageState.aiFollowUp(event.followUpData),
),
);
});
}
void _initializeStreamListener() {
@@ -137,6 +145,9 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
},
onLocalAIInitializing: () =>
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
onAIFollowUp: (data) {
_safeAdd(ChatAIMessageEvent.onAIFollowUp(data));
},
);
}
}
@@ -174,6 +185,9 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata,
) = _ReceiveMetadata;
const factory ChatAIMessageEvent.onAIFollowUp(
AIFollowUpData followUpData,
) = _OnAIFollowUp;
}
@freezed
@@ -209,4 +223,6 @@ class MessageState with _$MessageState {
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
const factory MessageState.ready() = _Ready;
const factory MessageState.loading() = _Loading;
const factory MessageState.aiFollowUp(AIFollowUpData followUpData) =
_AIFollowUp;
}
@@ -1,8 +1,6 @@
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';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
@@ -15,267 +13,291 @@ 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';
import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_handler.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
import 'chat_message_stream.dart';
import 'chat_settings_manager.dart';
import 'chat_stream_manager.dart';
part 'chat_bloc.freezed.dart';
/// Returns current Unix timestamp (seconds since epoch)
int timestamp() {
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
}
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required this.chatId,
required this.userId,
}) : chatController = InMemoryChatController(),
listener = ChatMessageListener(chatId: chatId),
selectedSourcesNotifier = ValueNotifier([]),
super(ChatState.initial()) {
// Initialize managers
_messageHandler = ChatMessageHandler(
chatId: chatId,
userId: userId,
chatController: chatController,
);
_streamManager = ChatStreamManager(chatId);
_settingsManager = ChatSettingsManager(chatId: chatId);
_startListening();
_dispatch();
_loadMessages();
_loadSetting();
_loadSettings();
}
final String chatId;
final String userId;
final ChatMessageListener listener;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final ChatController chatController;
/// The last streaming message id
String answerStreamMessageId = '';
String questionStreamMessageId = '';
// Managers
late final ChatMessageHandler _messageHandler;
late final ChatStreamManager _streamManager;
late final ChatSettingsManager _settingsManager;
ChatMessagePB? lastSentMessage;
/// Using a temporary map to associate the real message ID with the last streaming message ID.
///
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
/// between the real message ID and the last streaming message ID, we use this map to store the associations.
///
/// This map will be updated when receiving a message from the server and its author type
/// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap();
bool isLoadingPreviousMessages = false;
bool hasMorePreviousMessages = true;
AnswerStream? answerStream;
bool isFetchingRelatedQuestions = false;
bool shouldFetchRelatedQuestions = false;
// Accessor for selected sources
ValueNotifier<List<String>> get selectedSourcesNotifier =>
_settingsManager.selectedSourcesNotifier;
@override
Future<void> close() async {
await answerStream?.dispose();
// Safely dispose all resources
await _streamManager.dispose();
await listener.stop();
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
selectedSourcesNotifier.dispose();
_settingsManager.dispose();
chatController.dispose();
return super.close();
}
void _dispatch() {
on<ChatEvent>(
(event, emit) async {
await event.when(
// Loading messages
didLoadLatestMessages: (List<Message> messages) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
on<ChatEvent>((event, emit) async {
await event.when(
// Chat settings
didReceiveChatSettings: (settings) async =>
_handleChatSettings(settings),
updateSelectedSources: (selectedSourcesIds) async =>
_handleUpdateSources(selectedSourcesIds),
switch (state.loadingState) {
case LoadChatMessageStatus.loading
when chatController.messages.isEmpty:
emit(
state.copyWith(
loadingState: LoadChatMessageStatus.loadingRemote,
),
);
break;
case LoadChatMessageStatus.loading:
case LoadChatMessageStatus.loadingRemote:
emit(
state.copyWith(loadingState: LoadChatMessageStatus.ready),
);
break;
default:
break;
}
},
loadPreviousMessages: () {
if (isLoadingPreviousMessages) {
return;
}
// Message loading
didLoadLatestMessages: (messages) async =>
_handleLatestMessages(messages, emit),
loadPreviousMessages: () async => _loadPreviousMessagesIfNeeded(),
didLoadPreviousMessages: (messages, hasMore) async =>
_handlePreviousMessages(messages, hasMore),
final oldestMessage = _getOldestMessage();
// Message handling
receiveMessage: (message) async => _handleReceiveMessage(message),
if (oldestMessage != null) {
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
if (oldestMessageId == null) {
Log.error("Failed to parse message_id: ${oldestMessage.id}");
return;
}
isLoadingPreviousMessages = true;
_loadPreviousMessages(oldestMessageId);
}
},
didLoadPreviousMessages: (messages, hasMore) {
Log.debug("did load previous messages: ${messages.length}");
// Sending messages
sendMessage: (message, format, metadata) async =>
_handleSendMessage(message, format, metadata, emit),
finishSending: () async => emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
),
for (final message in messages) {
chatController.insert(message, index: 0);
}
// Stream control
stopStream: () async => _handleStopStream(emit),
failedSending: () async => _handleFailedSending(emit),
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
},
didFinishAnswerStream: () {
emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
);
},
didReceiveRelatedQuestions: (List<String> questions) {
if (questions.isEmpty) {
return;
}
// Answer regeneration
regenerateAnswer: (id, format, model) async =>
_handleRegenerateAnswer(id, format, model, emit),
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
// Streaming completion
didFinishAnswerStream: () async => emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
),
final createdAt = DateTime.now();
// Related questions
didReceiveRelatedQuestions: (questions) async =>
_handleRelatedQuestions(questions),
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
author: const User(id: systemUserId),
createdAt: createdAt,
);
// Message management
deleteMessage: (message) async => chatController.remove(message),
chatController.insert(message);
},
receiveMessage: (Message message) {
final oldMessage = chatController.messages
.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {
chatController.insert(message);
} else {
chatController.update(oldMessage, message);
}
},
sendMessage: (
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
) {
_clearErrorMessages(emit);
_clearRelatedQuestions();
_startStreamingMessage(message, format, metadata);
lastSentMessage = null;
// AI follow-up
onAIFollowUp: (followUpData) async {
shouldFetchRelatedQuestions =
followUpData.shouldGenerateRelatedQuestion;
},
);
});
}
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions =
format == null || format.imageFormat.hasText;
// Chat settings handlers
void _handleChatSettings(ChatSettingsPB settings) {
_settingsManager.selectedSourcesNotifier.value = settings.ragIds;
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
finishSending: () {
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
stopStream: () async {
if (answerStream == null) {
return;
}
Future<void> _handleUpdateSources(List<String> selectedSourcesIds) async {
await _settingsManager.updateSelectedSources(selectedSourcesIds);
}
// tell backend to stop
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
// Message loading handlers
Future<void> _handleLatestMessages(
List<Message> messages,
Emitter<ChatState> emit,
) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
// allow user input
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
// Check if emit is still valid after async operations
if (emit.isDone) {
return;
}
// no need to remove old message if stream has started already
if (answerStream!.hasStarted) {
return;
}
switch (state.loadingState) {
case LoadChatMessageStatus.loading when chatController.messages.isEmpty:
emit(state.copyWith(loadingState: LoadChatMessageStatus.loadingRemote));
break;
case LoadChatMessageStatus.loading:
case LoadChatMessageStatus.loadingRemote:
emit(state.copyWith(loadingState: LoadChatMessageStatus.ready));
break;
default:
break;
}
}
// remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
void _handlePreviousMessages(List<Message> messages, bool hasMore) {
for (final message in messages) {
chatController.insert(message, index: 0);
}
// set answer stream to null
await answerStream?.dispose();
answerStream = null;
answerStreamMessageId = '';
},
failedSending: () {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
chatController.remove(lastMessage);
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
},
regenerateAnswer: (id, format, model) {
_clearRelatedQuestions();
_regenerateAnswer(id, format, model);
lastSentMessage = null;
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
}
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = false;
// Message handling
void _handleReceiveMessage(Message message) {
final oldMessage =
chatController.messages.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {
chatController.insert(message);
} else {
chatController.update(oldMessage, message);
}
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
didReceiveChatSettings: (settings) {
selectedSourcesNotifier.value = settings.ragIds;
},
updateSelectedSources: (selectedSourcesIds) async {
selectedSourcesNotifier.value = [...selectedSourcesIds];
// Message sending handlers
void _handleSendMessage(
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
Emitter<ChatState> emit,
) {
_messageHandler.clearErrorMessages();
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
ragIds: selectedSourcesIds,
);
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
deleteMessage: (mesesage) async {
await chatController.remove(mesesage);
},
);
},
_messageHandler.clearRelatedQuestions();
_startStreamingMessage(message, format, metadata);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = format == null || format.imageFormat.hasText;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
}
// Stream control handlers
Future<void> _handleStopStream(Emitter<ChatState> emit) async {
await _streamManager.stopStream();
// Allow user input
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
// No need to remove old message if stream has started already
if (_streamManager.hasAnswerStreamStarted) {
return;
}
// Remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == _messageHandler.answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
await _streamManager.disposeAnswerStream();
}
void _handleFailedSending(Emitter<ChatState> emit) {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
chatController.remove(lastMessage);
}
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
}
// Answer regeneration handler
void _handleRegenerateAnswer(
String id,
PredefinedFormat? format,
AIModelPB? model,
Emitter<ChatState> emit,
) {
_messageHandler.clearRelatedQuestions();
_regenerateAnswer(id, format, model);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = false;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
}
// Related questions handler
void _handleRelatedQuestions(List<String> questions) {
if (questions.isEmpty) {
return;
}
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
final createdAt = DateTime.now();
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
author: const User(id: systemUserId),
createdAt: createdAt,
);
chatController.insert(message);
}
void _startListening() {
listener.start(
chatMessageCallback: (pb) {
@@ -283,25 +305,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return;
}
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => answerStreamMessageId,
);
answerStreamMessageId = '';
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => questionStreamMessageId,
);
questionStreamMessageId = '';
}
final message = _createTextMessage(pb);
_messageHandler.processReceivedMessage(pb);
final message = _messageHandler.createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
},
chatErrorMessageCallback: (err) {
@@ -312,13 +317,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
@@ -328,52 +335,55 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
add(const ChatEvent.didFinishAnswerStream());
// The answer stream will bet set to null after the streaming has
// finished, got cancelled, or errored. In this case, don't retrieve
// related questions.
if (answerStream == null ||
lastSentMessage == null ||
!shouldFetchRelatedQuestions) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: lastSentMessage!.messageId,
);
isFetchingRelatedQuestions = true;
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
// while fetching related questions, the user might enter a new
// question or regenerate a previous response. In such cases, don't
// display the relatedQuestions
if (!isClosed && isFetchingRelatedQuestions) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
isFetchingRelatedQuestions = false;
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
unawaited(_fetchRelatedQuestionsIfNeeded());
},
);
}
void _loadSetting() async {
// Split method to handle related questions
Future<void> _fetchRelatedQuestionsIfNeeded() async {
// Don't fetch related questions if conditions aren't met
if (_streamManager.answerStream == null ||
lastSentMessage == null ||
!shouldFetchRelatedQuestions) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: lastSentMessage!.messageId,
);
isFetchingRelatedQuestions = true;
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
// while fetching related questions, the user might enter a new
// question or regenerate a previous response. In such cases, don't
// display the relatedQuestions
if (!isClosed && isFetchingRelatedQuestions) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
isFetchingRelatedQuestions = false;
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
}
void _loadSettings() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().fold(
(settings) {
if (!isClosed) {
add(ChatEvent.didReceiveChatSettings(settings: settings));
}
},
Log.error,
(err) => Log.error("Failed to load chat settings: $err"),
);
}
@@ -382,10 +392,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
@@ -393,15 +405,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
bool _isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
void _loadPreviousMessagesIfNeeded() {
if (isLoadingPreviousMessages) {
return;
}
/// get the last message that is not a one-time message
Message? _getOldestMessage() {
return chatController.messages
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
final oldestMessage = _messageHandler.getOldestMessage();
if (oldestMessage != null) {
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
if (oldestMessageId == null) {
Log.error("Failed to parse message_id: ${oldestMessage.id}");
return;
}
isLoadingPreviousMessages = true;
_loadPreviousMessages(oldestMessageId);
}
}
void _loadPreviousMessages(Int64? beforeMessageId) {
@@ -418,36 +437,23 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
PredefinedFormat? format,
Map<String, dynamic>? metadata,
) async {
await answerStream?.dispose();
// Prepare streams
await _streamManager.prepareStreams();
answerStream = AnswerStream();
final questionStream = QuestionStream();
// add a streaming question message
final questionStreamMessage = _createQuestionStreamMessage(
questionStream,
// Create and add question message
final questionStreamMessage = _messageHandler.createQuestionStreamMessage(
_streamManager.questionStream!,
metadata,
);
add(ChatEvent.receiveMessage(questionStreamMessage));
final payload = StreamChatPayloadPB(
chatId: chatId,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
//metadata: await metadataPBFromMetadata(metadata),
);
if (format != null) {
payload.format = format.toPB();
}
// stream the question to the server
await AIEventStreamMessage(payload).send().fold(
// Send stream request
await _streamManager.sendStreamRequest(message, format).fold(
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
// Create and add answer stream message
final streamAnswer = _messageHandler.createAnswerStreamMessage(
stream: _streamManager.answerStream!,
questionMessageId: question.messageId,
fakeQuestionMessageId: questionStreamMessage.id,
);
@@ -481,143 +487,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
// Refactored method to handle answer regeneration
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
AIModelPB? model,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
answerMessageIdString;
final id = _messageHandler.getEffectiveMessageId(answerMessageIdString);
final answerMessageId = Int64.tryParseInt(id);
if (answerMessageId == null) {
return;
}
await answerStream?.dispose();
answerStream = AnswerStream();
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
if (model != null) {
payload.model = model;
}
await AIEventRegenerateResponse(payload).send().fold(
(success) {
await _streamManager.prepareStreams();
await _streamManager
.sendRegenerateRequest(
answerMessageId,
format,
model,
)
.fold(
(_) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
questionMessageId: answerMessageId - 1,
).copyWith(id: answerMessageIdString);
final streamAnswer = _messageHandler
.createAnswerStreamMessage(
stream: _streamManager.answerStream!,
questionMessageId: answerMessageId - 1,
)
.copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
(err) => Log.error("Failed to regenerate answer: ${err.msg}"),
);
}
Message _createAnswerStreamMessage({
required AnswerStream stream,
required Int64 questionMessageId,
String? fakeQuestionMessageId,
}) {
answerStreamMessageId = fakeQuestionMessageId == null
? (questionMessageId + 1).toString()
: "${fakeQuestionMessageId}_ans";
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
createdAt: DateTime.now(),
);
}
Message _createQuestionStreamMessage(
QuestionStream stream,
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
return TextMessage(
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
},
id: questionStreamMessageId,
createdAt: now,
text: '',
);
}
Message _createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
/// If the message id is in the temporary map, we will use the previous fake message id
if (temporaryMessageIDMap.containsKey(messageId)) {
messageId = temporaryMessageIDMap[messageId]!;
}
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: metadata,
},
);
}
void _clearErrorMessages(Emitter<ChatState> emit) {
final errorMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.error,
)
.toList();
for (final message in errorMessages) {
chatController.remove(message);
}
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
}
void _clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
}
@freezed
@@ -668,6 +573,9 @@ class ChatEvent with _$ChatEvent {
) = _DidReceiveRelatedQueston;
const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) =
_OnAIFollowUp;
}
@freezed
@@ -0,0 +1,171 @@
import 'dart:collection';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_stream.dart';
/// Returns current Unix timestamp (seconds since epoch)
int timestamp() {
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
}
/// Handles message creation and manipulation for the chat system
class ChatMessageHandler {
ChatMessageHandler({
required this.chatId,
required this.userId,
required this.chatController,
});
final String chatId;
final String userId;
final ChatController chatController;
/// Maps real message IDs to temporary streaming message IDs
final HashMap<String, String> _temporaryMessageIDMap = HashMap();
/// Gets the effective message ID from the temporary map
String getEffectiveMessageId(String messageId) {
return _temporaryMessageIDMap.entries
.firstWhereOrNull((entry) => entry.value == messageId)
?.key ??
messageId;
}
String answerStreamMessageId = '';
String questionStreamMessageId = '';
/// Create a message from ChatMessagePB object
Message createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
/// If the message id is in the temporary map, we will use the previous fake message id
if (_temporaryMessageIDMap.containsKey(messageId)) {
messageId = _temporaryMessageIDMap[messageId]!;
}
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: metadata,
},
);
}
/// Create a streaming answer message
Message createAnswerStreamMessage({
required AnswerStream stream,
required Int64 questionMessageId,
String? fakeQuestionMessageId,
}) {
answerStreamMessageId = fakeQuestionMessageId == null
? (questionMessageId + 1).toString()
: "${fakeQuestionMessageId}_ans";
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
createdAt: DateTime.now(),
);
}
/// Create a streaming question message
Message createQuestionStreamMessage(
QuestionStream stream,
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
questionStreamMessageId = timestamp().toString();
return TextMessage(
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
if (sentMetadata != null)
messageChatFileListKey: sentMetadata[messageChatFileListKey],
},
id: questionStreamMessageId,
createdAt: now,
text: '',
);
}
/// Clear error messages from the chat
void clearErrorMessages() {
final errorMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.error,
)
.toList();
for (final message in errorMessages) {
chatController.remove(message);
}
}
/// Clear related questions from the chat
void clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
/// Checks if a message is a one-time message
bool isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
/// Get the oldest message that is not a one-time message
Message? getOldestMessage() {
return chatController.messages
.firstWhereOrNull((message) => !isOneTimeMessage(message));
}
/// Add a message to the temporary ID map when receiving from server
void processReceivedMessage(ChatMessagePB pb) {
// 3 means message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
_temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => answerStreamMessageId,
);
answerStreamMessageId = '';
}
// 1 means message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
_temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => questionStreamMessageId,
);
questionStreamMessageId = '';
}
}
}
@@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:convert';
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';
import 'package:appflowy_backend/log.dart';
import 'package:json_annotation/json_annotation.dart';
part 'chat_message_stream.g.dart';
/// A stream that receives answer events from an isolate or external process.
/// It caches events that might occur before a listener is attached.
@@ -37,7 +42,7 @@ class AnswerStream {
void Function()? _onAIImageResponseLimit;
void Function(String message)? _onAIMaxRequired;
void Function(MetadataCollection metadata)? _onMetadata;
void Function(AIFollowUpData)? _onAIFollowUp;
// Caches for events that occur before listen() is called.
final List<String> _pendingAIMaxRequiredEvents = [];
bool _pendingLocalAINotReady = false;
@@ -88,6 +93,15 @@ class AnswerStream {
} else {
_pendingLocalAINotReady = true;
}
} else if (event.startsWith(AIStreamEventPrefix.aiFollowUp)) {
final s = event.substring(AIStreamEventPrefix.aiFollowUp.length);
try {
final dynamic jsonData = jsonDecode(s);
final data = AIFollowUpData.fromJson(jsonData);
_onAIFollowUp?.call(data);
} catch (e) {
Log.error('Error deserializing AIFollowUp data: $e\nRaw JSON: $s');
}
}
}
@@ -114,6 +128,7 @@ class AnswerStream {
void Function(String message)? onAIMaxRequired,
void Function(MetadataCollection metadata)? onMetadata,
void Function()? onLocalAIInitializing,
void Function(AIFollowUpData)? onAIFollowUp,
}) {
_onData = onData;
_onStart = onStart;
@@ -124,7 +139,7 @@ class AnswerStream {
_onAIMaxRequired = onAIMaxRequired;
_onMetadata = onMetadata;
_onLocalAIInitializing = onLocalAIInitializing;
_onAIFollowUp = onAIFollowUp;
// Flush pending AI_MAX_REQUIRED events.
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
for (final msg in _pendingAIMaxRequiredEvents) {
@@ -205,7 +220,6 @@ class QuestionStream {
void Function()? _onIndexStart;
void Function()? _onIndexEnd;
void Function()? _onDone;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
@@ -241,3 +255,18 @@ class QuestionStream {
_onDone = onDone;
}
}
@JsonSerializable()
class AIFollowUpData {
AIFollowUpData({
required this.shouldGenerateRelatedQuestion,
});
factory AIFollowUpData.fromJson(Map<String, dynamic> json) =>
_$AIFollowUpDataFromJson(json);
@JsonKey(name: 'should_generate_related_question')
final bool shouldGenerateRelatedQuestion;
Map<String, dynamic> toJson() => _$AIFollowUpDataToJson(this);
}
@@ -0,0 +1,48 @@
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:flutter/foundation.dart';
/// Manages settings for a chat
class ChatSettingsManager {
ChatSettingsManager({
required this.chatId,
}) : selectedSourcesNotifier = ValueNotifier([]);
final String chatId;
/// Notifies listeners when selected sources change
final ValueNotifier<List<String>> selectedSourcesNotifier;
/// Load settings from backend
Future<void> loadSettings() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().then((result) {
result.fold(
(settings) {
selectedSourcesNotifier.value = settings.ragIds;
},
(err) => Log.error("Failed to load chat settings: $err"),
);
});
}
/// Update selected sources
Future<void> updateSelectedSources(List<String> selectedSourcesIds) async {
selectedSourcesNotifier.value = [...selectedSourcesIds];
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
ragIds: selectedSourcesIds,
);
await AIEventUpdateChatSettings(payload).send().onFailure(Log.error);
}
/// Clean up resources
void dispose() {
selectedSourcesNotifier.dispose();
}
}
@@ -0,0 +1,120 @@
import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:fixnum/fixnum.dart';
import 'chat_message_stream.dart';
/// Manages chat streaming operations
class ChatStreamManager {
ChatStreamManager(this.chatId);
final String chatId;
AnswerStream? answerStream;
QuestionStream? questionStream;
/// Dispose of all streams
Future<void> dispose() async {
await answerStream?.dispose();
answerStream = null;
await questionStream?.dispose();
questionStream = null;
}
/// Prepare streams for a new message
Future<void> prepareStreams() async {
await dispose();
answerStream = AnswerStream();
questionStream = QuestionStream();
}
/// Build the payload for a streaming message
StreamChatPayloadPB buildStreamPayload(
String message,
PredefinedFormat? format,
) {
final payload = StreamChatPayloadPB(
chatId: chatId,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream!.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
return payload;
}
/// Send a streaming message request to the server
Future<FlowyResult<ChatMessagePB, FlowyError>> sendStreamRequest(
String message,
PredefinedFormat? format,
) async {
final payload = buildStreamPayload(message, format);
return AIEventStreamMessage(payload).send();
}
/// Build the payload for regenerating a response
RegenerateResponsePB buildRegeneratePayload(
Int64 answerMessageId,
PredefinedFormat? format,
AIModelPB? model,
) {
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
if (model != null) {
payload.model = model;
}
return payload;
}
/// Send a request to regenerate a response
Future<FlowyResult<dynamic, FlowyError>> sendRegenerateRequest(
Int64 answerMessageId,
PredefinedFormat? format,
AIModelPB? model,
) async {
final payload = buildRegeneratePayload(answerMessageId, format, model);
return AIEventRegenerateResponse(payload).send();
}
/// Stop the current streaming message
Future<void> stopStream() async {
if (answerStream == null) {
return;
}
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
}
/// Check if the answer stream has started
bool get hasAnswerStreamStarted =>
answerStream != null && answerStream!.hasStarted;
Future<void> disposeAnswerStream() async {
if (answerStream == null) {
return;
}
await answerStream!.dispose();
answerStream = null;
}
}
@@ -0,0 +1,78 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// ChatUserCubit is responsible for fetching and storing the user profile
class ChatUserCubit extends Cubit<ChatUserState> {
ChatUserCubit() : super(ChatUserLoadingState()) {
fetchUserProfile();
}
/// Fetches the user profile from the AuthService
Future<void> fetchUserProfile() async {
emit(ChatUserLoadingState());
final userOrFailure = await getIt<AuthService>().getUser();
userOrFailure.fold(
(userProfile) => emit(ChatUserSuccessState(userProfile)),
(error) => emit(ChatUserFailureState(error)),
);
}
bool supportSelectSource() {
if (state is ChatUserSuccessState) {
final userProfile = (state as ChatUserSuccessState).userProfile;
if (userProfile.userAuthType == AuthTypePB.Server) {
return true;
}
}
return false;
}
bool isValueWorkspace() {
if (state is ChatUserSuccessState) {
final userProfile = (state as ChatUserSuccessState).userProfile;
return userProfile.workspaceType == WorkspaceTypePB.LocalW &&
userProfile.userAuthType != AuthTypePB.Local;
}
return false;
}
/// Refreshes the user profile data
Future<void> refresh() async {
await fetchUserProfile();
}
}
/// Base state class for ChatUserCubit
abstract class ChatUserState extends Equatable {
const ChatUserState();
@override
List<Object?> get props => [];
}
/// Loading state when fetching user profile
class ChatUserLoadingState extends ChatUserState {}
/// Success state when user profile is fetched successfully
class ChatUserSuccessState extends ChatUserState {
const ChatUserSuccessState(this.userProfile);
final UserProfilePB userProfile;
@override
List<Object?> get props => [userProfile];
}
/// Failure state when fetching user profile fails
class ChatUserFailureState extends ChatUserState {
const ChatUserFailureState(this.error);
final FlowyError error;
@override
List<Object?> get props => [error];
}
@@ -1,39 +1,15 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package: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'
hide ChatAnimatedListReversed;
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.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/mobile_chat_input.dart';
import 'presentation/chat_related_question.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/error_text_message.dart';
import 'presentation/message/message_util.dart';
import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
class AIChatPage extends StatelessWidget {
const AIChatPage({
@@ -49,15 +25,6 @@ class AIChatPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// if (userProfile.authenticator != AuthTypePB.Server) {
// return Center(
// child: FlowyText(
// LocaleKeys.chat_unsupportedCloudPrompt.tr(),
// fontSize: 20,
// ),
// );
// }
return MultiBlocProvider(
providers: [
/// [ChatBloc] is used to handle chat messages including send/receive message
@@ -111,7 +78,7 @@ class AIChatPage extends StatelessWidget {
return KeyEventResult.ignored;
},
child: _ChatContentPage(
child: ChatContentPage(
view: view,
userProfile: userProfile,
),
@@ -122,371 +89,3 @@ class AIChatPage extends StatelessWidget {
);
}
}
class _ChatContentPage extends StatelessWidget {
const _ChatContentPage({
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
ChatMessageSelectorBanner(
view: view,
allMessages: context.read<ChatBloc>().chatController.messages,
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
SelectionArea(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: Chat(
chatController:
context.read<ChatBloc>().chatController,
user: User(id: userProfile.id.toString()),
darkTheme:
ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
),
),
),
),
),
_wrapConstraints(
_Input(view: view),
),
],
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
Widget _buildTextMessage(
BuildContext context,
TextMessage message,
) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (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() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
);
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.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(),
),
);
},
);
},
);
}
Widget _buildChatMessage(
BuildContext context,
Message message,
Animation<double> animation,
Widget child,
) {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
);
}
Widget _buildScrollToBottom(
BuildContext context,
Animation<double> animation,
VoidCallback onPressed,
) {
return CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
);
}
Widget _buildChatAnimatedList(
BuildContext context,
ScrollController scrollController,
ChatItem itemBuilder,
) {
final bloc = context.read<ChatBloc>();
if (bloc.chatController.messages.isEmpty) {
return ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (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) {
return ChatAnimatedListReversed(
scrollController: scrollController,
itemBuilder: itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
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 = AiPromptInputTextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
)
: MobileChatInput(
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
},
),
),
);
},
);
}
}
@@ -1,18 +1,20 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy_backend/log.dart';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
class ChatAnimatedList extends StatefulWidget {
const ChatAnimatedList({
super.key,
required this.scrollController,
required this.itemBuilder,
@@ -22,6 +24,7 @@ class ChatAnimatedListReversed extends StatefulWidget {
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
this.scrollBottomPadding = 440,
});
final ScrollController scrollController;
@@ -32,16 +35,18 @@ class ChatAnimatedListReversed extends StatefulWidget {
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
final double scrollBottomPadding;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
ChatAnimatedListState createState() => ChatAnimatedListState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
class ChatAnimatedListState extends State<ChatAnimatedList>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late ChatController _chatController;
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
@@ -49,17 +54,38 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
final ScrollOffsetController scrollOffsetController =
ScrollOffsetController();
final ItemScrollController itemScrollController = ItemScrollController();
final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int _lastUserMessageIndex = 0;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
final _loadPreviousMessagesDebounce = Debounce(
duration: const Duration(milliseconds: 200),
);
int initialScrollIndex = 0;
double initialAlignment = 1.0;
List<Message> messages = [];
@override
void initState() {
super.initState();
_chatController = Provider.of<ChatController>(context, listen: false);
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
setState(() {
messages = _chatController.messages;
});
switch (event.type) {
case ChatOperationType.insert:
assert(
@@ -70,6 +96,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
@@ -82,6 +109,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
@@ -105,180 +133,139 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
}
});
messages = _chatController.messages;
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
itemPositionsListener.itemPositions.addListener(() {
_handleToggleScrollToBottom();
});
itemPositionsListener.itemPositions.addListener(() {
_handleLoadPreviousMessages();
});
// A trick to avoid the first message being scrolled to the top
}
@override
void dispose() {
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
super.dispose();
}
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
// A trick to avoid the first message being scrolled to the top
return NotificationListener<Notification>(
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
_userHasScrolled = false;
initialScrollIndex = messages.length;
initialAlignment = 1.0;
if (messages.length <= 2) {
initialScrollIndex = 0;
initialAlignment = 0.0;
}
final Widget child = Stack(
children: [
ScrollablePositionedList.builder(
scrollOffsetController: scrollOffsetController,
itemScrollController: itemScrollController,
initialScrollIndex: initialScrollIndex,
initialAlignment: initialAlignment,
scrollOffsetListener: scrollOffsetListener,
itemPositionsListener: itemPositionsListener,
physics: ClampingScrollPhysics(),
shrinkWrap: true,
// the extra item is a vertical padding.
itemCount: messages.length + 1,
itemBuilder: (context, index) {
if (index < 0 || index > messages.length) {
Log.error('[chat animation list] index out of range: $index');
return const SizedBox.shrink();
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
if (index == messages.length) {
return VSpace(height - 400);
}
// Allow other listeners to get the notification
return false;
},
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
final message = messages[index];
return widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
),
),
SliverAnimatedList(
key: _listKey,
initialItemCount: _chatController.messages.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
context,
animation,
message,
);
},
),
],
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
),
message,
);
},
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
);
return child;
}
void _subsequentScrollToEnd(Message data) async {
Future<void> _scrollLastUserMessageToTop() async {
final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
void _scrollToEnd(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
},
final lastUserMessageIndex = messages.lastIndexWhere(
(message) => message.author.id == user.id,
);
if (lastUserMessageIndex == -1) {
return;
}
if (_lastUserMessageIndex != lastUserMessageIndex) {
// scroll the current message to the top
await itemScrollController.scrollTo(
index: lastUserMessageIndex,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
_lastUserMessageIndex = lastUserMessageIndex;
}
void _handleScrollToBottom() {
Future<void> _handleScrollToBottom() async {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
_scrollToBottomShowTimer?.cancel();
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
await _scrollToBottomController.reverse();
if (!widget.scrollController.hasClients || !mounted) return;
await itemScrollController.scrollTo(
index: messages.length + 1,
alignment: 1.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
_isScrollingToBottom = false;
}
void _handleToggleScrollToBottom() {
@@ -286,85 +273,55 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
return;
}
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
// get the max item
final sortedItems = itemPositionsListener.itemPositions.value.toList()
..sort((a, b) => a.index.compareTo(b.index));
final maxItem = sortedItems.lastOrNull;
if (maxItem == null) {
return;
}
if (maxItem.index > messages.length - 1 ||
(maxItem.index == messages.length - 1 &&
maxItem.itemTrailingEdge <= 1.01)) {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.reverse();
}
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_userHasScrolled = false;
return;
}
_listKey.currentState!.insertItem(
0,
duration: widget.insertAnimationDuration,
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToEnd(data);
}
}
void _onRemoved(final int position, final Message data) {
final visualPosition = max(_oldList.length - position - 1, 0);
_listKey.currentState!.removeItem(
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
_scrollToBottomShowTimer?.cancel();
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
final sortedItems = itemPositionsListener.itemPositions.value.toList()
..sort((a, b) => a.index.compareTo(b.index));
final minItem = sortedItems.firstOrNull;
if (minItem == null || minItem.index > 0 || minItem.itemLeadingEdge < 0) {
return;
}
_loadPreviousMessagesDebounce.call(
() {
widget.onLoadPreviousMessages?.call();
},
);
}
Future<void> _onInserted(final int position, final Message data) async {
// scroll the last user message to the top if it's the last message
if (position == _oldList.length) {
await _scrollLastUserMessageToTop();
}
}
void _onRemoved(final int position, final Message data) {}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
}
@@ -0,0 +1,375 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
super.key,
required this.scrollController,
required this.itemBuilder,
this.insertAnimationDuration = const Duration(milliseconds: 250),
this.removeAnimationDuration = const Duration(milliseconds: 250),
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
});
final ScrollController scrollController;
final ChatItem itemBuilder;
final Duration insertAnimationDuration;
final Duration removeAnimationDuration;
final Duration scrollToEndAnimationDuration;
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
@override
void initState() {
super.initState();
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
switch (event.type) {
case ChatOperationType.insert:
assert(
event.index != null,
'Index must be provided when inserting a message.',
);
assert(
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.remove:
assert(
event.index != null,
'Index must be provided when removing a message.',
);
assert(
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.set:
final newList = _chatController.messages;
final updates = diffutil
.calculateDiff<Message>(
MessageListDiff(_oldList, newList),
)
.getUpdatesWithData();
for (var i = updates.length - 1; i >= 0; i--) {
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
break;
default:
break;
}
});
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
}
@override
void dispose() {
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
}
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
return NotificationListener<Notification>(
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
_userHasScrolled = false;
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
// Allow other listeners to get the notification
return false;
},
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
),
),
SliverAnimatedList(
key: _listKey,
initialItemCount: _chatController.messages.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
context,
animation,
message,
);
},
),
],
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
),
);
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
void _scrollToEnd(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
},
);
}
void _handleScrollToBottom() {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
return;
}
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
_scrollToBottomController.reverse();
}
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_userHasScrolled = false;
}
_listKey.currentState!.insertItem(
0,
duration: widget.insertAnimationDuration,
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToEnd(data);
}
}
void _onRemoved(final int position, final Message data) {
final visualPosition = max(_oldList.length - position - 1, 0);
_listKey.currentState!.removeItem(
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
}
}
}
@@ -1,17 +1,17 @@
import 'dart:async';
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_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@@ -133,23 +133,22 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: (item) {
final view = item.view;
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
),
BlocProvider(
create: (context) => ChatSettingsCubit(hideDisabled: true),
),
],
),
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
@@ -188,9 +187,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
await updateSelection(documentId);
} else {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources([spaceView], spaceView);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView),
);
}
popoverController.show();
}
@@ -210,7 +211,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) async {
await addMessageToNewPage(context, parentViewId);
@@ -0,0 +1,98 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list.dart';
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list_reversed.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_welcome_page.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
@visibleForTesting
bool skipAIChatWelcomePage = false;
class ChatAnimationListWidget extends StatefulWidget {
const ChatAnimationListWidget({
super.key,
required this.userProfile,
required this.scrollController,
required this.itemBuilder,
this.enableReversedList = false,
});
final UserProfilePB userProfile;
final ScrollController scrollController;
final ChatItem itemBuilder;
final bool enableReversedList;
@override
State<ChatAnimationListWidget> createState() =>
_ChatAnimationListWidgetState();
}
class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
@override
Widget build(BuildContext context) {
final bloc = context.read<ChatBloc>();
// this logic is quite weird, why don't we just get the message from the state?
if (bloc.chatController.messages.isEmpty && !skipAIChatWelcomePage) {
return ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (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,
),
);
},
);
}
// don't call this in the build method
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
// final bool reversed = false;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return widget.enableReversedList
? ChatAnimatedListReversed(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
: ChatAnimatedList(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
if (bloc.isClosed) {
return;
}
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
}
@@ -0,0 +1,33 @@
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/load_chat_message_status_ready.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatContentPage extends StatelessWidget {
const ChatContentPage({
super.key,
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => LoadChatMessageStatusReady(
view: view,
userProfile: userProfile,
chatController: context.read<ChatBloc>().chatController,
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
}
@@ -0,0 +1,137 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatFooter extends StatefulWidget {
const ChatFooter({
super.key,
required this.view,
});
final ViewPB view;
@override
State<ChatFooter> createState() => _ChatFooterState();
}
class _ChatFooterState extends State<ChatFooter> {
final textController = AiPromptInputTextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? _buildDesktopInput(
context,
chatBloc,
canSendMessage,
)
: _buildMobileInput(
context,
chatBloc,
canSendMessage,
);
},
),
),
);
},
);
}
Widget _buildDesktopInput(
BuildContext context,
ChatBloc chatBloc,
bool canSendMessage,
) {
return DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
}
Widget _buildMobileInput(
BuildContext context,
ChatBloc chatBloc,
bool canSendMessage,
) {
return MobileChatInput(
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
}
}
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:provider/provider.dart';
class ChatMessage extends StatelessWidget {
const ChatMessage({
super.key,
required this.message,
required this.child,
this.sentMessageAlignment = AlignmentDirectional.centerEnd,
this.receivedMessageAlignment = AlignmentDirectional.centerStart,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
});
final Message message;
final Widget child;
final AlignmentGeometry sentMessageAlignment;
final AlignmentGeometry receivedMessageAlignment;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final isSentByMe = context.watch<User>().id == message.author.id;
return Align(
alignment: isSentByMe ? sentMessageAlignment : receivedMessageAlignment,
child: Padding(
padding: padding,
child: child,
),
);
}
}
@@ -0,0 +1,131 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_footer.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_message_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/text_message_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/scroll_to_bottom.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart' hide ChatMessage;
import 'package:universal_platform/universal_platform.dart';
class LoadChatMessageStatusReady extends StatelessWidget {
const LoadChatMessageStatusReady({
super.key,
required this.view,
required this.userProfile,
required this.chatController,
});
final ViewPB view;
final UserProfilePB userProfile;
final ChatController chatController;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Chat header, banner
_buildHeader(context),
// Chat body, a list of messages
_buildBody(context),
// Chat footer, a text input field with toolbar, send button, etc.
_buildFooter(context),
],
);
}
Widget _buildHeader(BuildContext context) {
return ChatMessageSelectorBanner(
view: view,
allMessages: chatController.messages,
);
}
Widget _buildBody(BuildContext context) {
final bool enableAnimation = true;
return Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
SelectionArea(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
),
child: Chat(
chatController: chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
// we have a custom input builder, so we don't need the default one
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: (
context,
message,
) =>
TextMessageWidget(
message: message,
userProfile: userProfile,
view: view,
enableAnimation: enableAnimation,
),
chatMessageBuilder: (
context,
message,
animation,
child,
) =>
ChatMessage(
message: message,
padding: const EdgeInsets.symmetric(vertical: 18.0),
child: child,
),
scrollToBottomBuilder: (
context,
animation,
onPressed,
) =>
CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
),
chatAnimatedListBuilder: (
context,
scrollController,
itemBuilder,
) =>
ChatAnimationListWidget(
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: !enableAnimation,
),
),
),
),
),
),
),
);
}
Widget _buildFooter(BuildContext context) {
return _wrapConstraints(
ChatFooter(view: view),
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
}
@@ -0,0 +1,156 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/message_util.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/user_text_message.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart';
class TextMessageWidget extends StatelessWidget {
const TextMessageWidget({
super.key,
required this.message,
required this.userProfile,
required this.view,
this.enableAnimation = true,
});
final TextMessage message;
final UserProfilePB userProfile;
final ViewPB view;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (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() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
);
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
buildWhen: (previous, current) =>
previous.promptResponseState != current.promptResponseState,
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.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(),
),
);
},
);
},
);
}
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;
}
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
@@ -17,16 +19,21 @@ class AIMarkdownText extends StatelessWidget {
const AIMarkdownText({
super.key,
required this.markdown,
this.withAnimation = false,
});
final String markdown;
final bool withAnimation;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DocumentPageStyleBloc(view: ViewPB())
..add(const DocumentPageStyleEvent.initial()),
child: _AppFlowyEditorMarkdown(markdown: markdown),
child: _AppFlowyEditorMarkdown(
markdown: markdown,
withAnimation: withAnimation,
),
);
}
}
@@ -34,19 +41,28 @@ class AIMarkdownText extends StatelessWidget {
class _AppFlowyEditorMarkdown extends StatefulWidget {
const _AppFlowyEditorMarkdown({
required this.markdown,
this.withAnimation = false,
});
// the text should be the markdown format
final String markdown;
/// Whether to animate the text.
final bool withAnimation;
@override
State<_AppFlowyEditorMarkdown> createState() =>
_AppFlowyEditorMarkdownState();
}
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
with TickerProviderStateMixin {
late EditorState editorState;
late EditorScrollController scrollController;
late Timer markdownOutputTimer;
int offset = 0;
final Map<String, (AnimationController, Animation<double>)> _animations = {};
@override
void initState() {
@@ -57,13 +73,46 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
editorState: editorState,
shrinkWrap: true,
);
if (widget.withAnimation) {
markdownOutputTimer =
Timer.periodic(const Duration(milliseconds: 60), (timer) {
if (offset >= widget.markdown.length || !widget.withAnimation) {
return;
}
final markdown = widget.markdown.substring(0, offset);
offset += 30;
final editorState = _parseMarkdown(
markdown,
previousDocument: this.editorState.document,
);
final lastCurrentNode = editorState.document.last;
final lastPreviousNode = this.editorState.document.last;
if (lastCurrentNode?.id != lastPreviousNode?.id ||
lastCurrentNode?.type != lastPreviousNode?.type ||
lastCurrentNode?.delta?.toPlainText() !=
lastPreviousNode?.delta?.toPlainText()) {
setState(() {
this.editorState.dispose();
this.editorState = editorState;
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
});
}
});
}
}
@override
void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
if (oldWidget.markdown != widget.markdown && !widget.withAnimation) {
final editorState = _parseMarkdown(
widget.markdown.trim(),
previousDocument: this.editorState.document,
@@ -83,6 +132,13 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
scrollController.dispose();
editorState.dispose();
if (widget.withAnimation) {
markdownOutputTimer.cancel();
for (final controller in _animations.values.map((e) => e.$1)) {
controller.dispose();
}
}
super.dispose();
}
@@ -120,6 +176,33 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
commandShortcutEvents: [customCopyCommand],
disableAutoScroll: true,
editorState: editorState,
blockWrapper: (
context, {
required Node node,
required Widget child,
}) {
if (!widget.withAnimation) {
return child;
}
if (!_animations.containsKey(node.id)) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
);
final fade = Tween<double>(
begin: 0,
end: 1,
).animate(controller);
_animations[node.id] = (controller, fade);
controller.forward();
}
final (controller, fade) = _animations[node.id]!;
return _AnimatedWrapper(
fade: fade,
child: child,
);
},
contextMenuItems: [
[
ContextMenuItem(
@@ -161,3 +244,39 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
return editorState;
}
}
class _AnimatedWrapper extends StatelessWidget {
const _AnimatedWrapper({
required this.fade,
required this.child,
});
final Animation<double> fade;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: fade,
builder: (context, childWidget) {
return ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
stops: [fade.value, fade.value],
colors: const [
Colors.white,
Colors.transparent,
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: Opacity(
opacity: fade.value,
child: childWidget,
),
);
},
child: child,
);
}
}
@@ -6,7 +6,6 @@ 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_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';
import 'package:appflowy/shared/markdown_to_document.dart';
@@ -15,16 +14,15 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
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';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -515,23 +513,23 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: (item) {
final view = item.view;
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
),
BlocProvider(
create: (context) => ChatSettingsCubit(hideDisabled: true),
),
],
),
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
@@ -546,7 +544,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
onClose: () {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView);
}
widget.onOverrideVisibility?.call(false);
@@ -583,9 +581,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
} else {
widget.onOverrideVisibility?.call(true);
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources([spaceView], spaceView);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView),
);
}
popoverController.show();
}
@@ -596,7 +596,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) {
addMessageToNewPage(context, parentViewId);
@@ -697,8 +697,10 @@ class SaveToPagePopoverContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -707,22 +709,26 @@ class SaveToPagePopoverContent extends StatelessWidget {
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: FlowyText(
child: Text(
LocaleKeys.chat_addToPageTitle.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.secondary),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
child: AFTextField(
controller:
context.read<ViewSelectorCubit>().filterTextController,
hintText: LocaleKeys.search_label.tr(),
size: AFTextFieldSize.m,
),
),
_buildDivider(),
AFDivider(
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
Expanded(
child: ListView(
shrinkWrap: true,
@@ -736,44 +742,33 @@ class SaveToPagePopoverContent extends StatelessWidget {
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 12.0,
endIndent: 12.0,
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'save_to_page_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
showSaveButton: true,
onSelected: (source) {
if (source.view.isSpace) {
onAddToNewPage(source.view.id);
} else {
onAddToExistingPage(source.view.id);
}
},
onAdd: (source) {
onAddToNewPage(source.view.id);
},
height: 30.0,
),
);
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'save_to_page_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
showSaveButton: true,
onSelected: (source) {
if (source.view.isSpace) {
onAddToNewPage(source.view.id);
} else {
onAddToExistingPage(source.view.id);
}
},
onAdd: (source) {
onAddToNewPage(source.view.id);
},
height: 30.0,
),
);
}
}
@@ -23,9 +23,9 @@ import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../layout_define.dart';
import 'ai_change_format_bottom_sheet.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';
/// Wraps an AI response message with the avatar and actions. On desktop,
@@ -132,7 +132,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
const VSpace(32.0),
const VSpace(16.0),
],
);
}
@@ -41,6 +41,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
});
final User user;
@@ -59,6 +60,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
@@ -69,105 +71,197 @@ class ChatAIMessageWidget extends StatelessWidget {
chatId: chatId,
questionId: questionId,
),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
final loadingText =
state.progress?.step ?? LocaleKeys.chat_generatingResponse.tr();
child: BlocConsumer<ChatAIMessageBloc, ChatAIMessageState>(
listenWhen: (previous, current) =>
previous.messageState != current.messageState,
listener: (context, state) => _handleMessageState(state, context),
builder: (context, blocState) {
final loadingText = blocState.progress?.step ??
LocaleKeys.chat_generatingResponse.tr();
return BlocListener<ChatBloc, ChatState>(
listenWhen: (previous, current) =>
previous.clearErrorMessages != current.clearErrorMessages,
listener: (context, chatState) {
if (state.stream?.error?.isEmpty != false) {
return;
}
context.read<ChatBloc>().add(ChatEvent.deleteMessage(message));
},
child: Padding(
padding: AIChatUILayout.messageMargin,
child: state.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
return Padding(
padding: AIChatUILayout.messageMargin,
child: blocState.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
ready: () {
return state.text.isEmpty
? ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
)
: ChatAIMessageBubble(
message: message,
isLastMessage: isLastMessage,
showActions: stream == null &&
state.text.isNotEmpty &&
!isStreaming,
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage)
const VSpace(8.0),
],
),
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
},
onAIImageResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
);
},
onAIMaxRequired: (message) {
return ChatErrorMessageWidget(
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage: LocaleKeys
.settings_aiPage_keys_localAIInitializing
.tr(),
);
},
),
ready: () {
return blocState.text.isEmpty
? _LoadingMessage(
message: message,
loadingText: loadingText,
)
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
},
onAIImageResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
);
},
onAIMaxRequired: (message) {
return ChatErrorMessageWidget(
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
);
},
aiFollowUp: (followUpData) {
return const SizedBox.shrink();
},
),
);
},
),
);
}
void _handleMessageState(ChatAIMessageState state, BuildContext context) {
if (state.stream?.error?.isEmpty != false) {
state.messageState.maybeMap(
aiFollowUp: (messageState) {
context
.read<ChatBloc>()
.add(ChatEvent.onAIFollowUp(messageState.followUpData));
},
orElse: () {
// do nothing
},
);
return;
}
context.read<ChatBloc>().add(ChatEvent.deleteMessage(message));
}
}
class _LoadingMessage extends StatelessWidget {
const _LoadingMessage({
required this.message,
required this.loadingText,
});
final Message message;
final String loadingText;
@override
Widget build(BuildContext context) {
return ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
);
}
}
class _NonEmptyMessage extends StatelessWidget {
const _NonEmptyMessage({
required this.user,
required this.messageUserId,
required this.message,
required this.stream,
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,
this.enableAnimation = true,
});
final User user;
final String messageUserId;
final Message message;
final AnswerStream? stream;
final Int64? questionId;
final String chatId;
final String? refSourceJsonString;
final ValueChanged<ChatMessageRefSource>? onSelectedMetadata;
final VoidCallback? onRegenerate;
final VoidCallback onStopStream;
final ValueChanged<PredefinedFormat>? onChangeFormat;
final ValueChanged<AIModelPB>? onChangeModel;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
final state = context.read<ChatAIMessageBloc>().state;
final showActions = stream == null && state.text.isNotEmpty && !isStreaming;
return ChatAIMessageBubble(
message: message,
isLastMessage: isLastMessage,
showActions: showActions,
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
withAnimation: enableAnimation && stream != null,
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage) const VSpace(8.0),
],
),
);
}
}
@@ -219,7 +219,8 @@ class DateCellEditorBloc
} else {
if (option == ReminderOption.none) {
// remove reminder from reminder bloc and cell data
_reminderBloc.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
_reminderBloc
.add(ReminderEvent.removeReminder(reminderId: state.reminderId));
await _updateCellReminderId("");
} else {
// Update reminder
@@ -109,6 +109,10 @@ class EditorDropHandler extends StatelessWidget {
void _onDragUpdated(Offset position) {
final data = editorState.selectionService.getDropTargetRenderData(position);
if (dropManagerState?.isDropEnabled == false) {
return editorState.selectionService.removeDropTarget();
}
if (data != null &&
data.dropPath != null &&
@@ -14,6 +14,8 @@ class EditorDropManagerState extends ChangeNotifier {
}
bool get isDropEnabled => _draggedTypes.isEmpty;
bool contains(String type) => _draggedTypes.contains(type);
}
final enableDocumentDragNotifier = ValueNotifier(true);
@@ -3,7 +3,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class BulletedListIcon extends StatelessWidget {
class BulletedListIcon extends StatefulWidget {
const BulletedListIcon({
super.key,
required this.node,
@@ -17,28 +17,30 @@ class BulletedListIcon extends StatelessWidget {
FlowySvgs.bulleted_list_icon_3_s,
];
int get level {
var level = 0;
var parent = node.parent;
while (parent != null) {
if (parent.type == BulletedListBlockKeys.type) {
level++;
}
parent = parent.parent;
}
return level;
}
@override
State<BulletedListIcon> createState() => _BulletedListIconState();
}
class _BulletedListIconState extends State<BulletedListIcon> {
int index = 0;
double size = 0.0;
@override
Widget build(BuildContext context) {
void initState() {
super.initState();
final textStyle =
context.read<EditorState>().editorStyle.textStyleConfiguration;
final fontSize = textStyle.text.fontSize ?? 16.0;
final height = textStyle.text.height ?? textStyle.lineHeight;
final size = fontSize * height;
final index = level % bulletedListIcons.length;
index = level % BulletedListIcon.bulletedListIcons.length;
size = fontSize * height;
}
@override
Widget build(BuildContext context) {
final icon = FlowySvg(
bulletedListIcons[index],
BulletedListIcon.bulletedListIcons[index],
size: Size.square(size * 0.8),
);
return Container(
@@ -49,4 +51,16 @@ class BulletedListIcon extends StatelessWidget {
child: icon,
);
}
int get level {
var level = 0;
var parent = widget.node.parent;
while (parent != null) {
if (parent.type == BulletedListBlockKeys.type) {
level++;
}
parent = parent.parent;
}
return level;
}
}
@@ -27,11 +27,12 @@ extension LinkExtension on EditorState {
final node = getNodeAtPath(selection.start.path);
if (node == null) return;
final transaction = this.transaction;
final linkName = info.name.isEmpty ? info.link : info.name;
transaction.replaceText(
node,
selection.startIndex,
selection.length,
info.name,
linkName,
attributes: info.toAttribute(),
);
apply(transaction);
@@ -186,8 +186,11 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) => editorState.applyLink(selection, info),
onRemoveLink: (linkinfo) =>
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
onRemoveLink: (linkinfo) {
final replaceText =
linkinfo.name.isEmpty ? linkinfo.link : linkinfo.name;
onRemoveAndReplaceLink(editorState, selection, replaceText);
},
),
child: child,
);
@@ -157,6 +157,8 @@ class FileBlockComponent extends BlockComponentStatefulWidget {
super.configuration = const BlockComponentConfiguration(),
});
static const uploadDragKey = 'FileUploadMenu';
@override
State<FileBlockComponent> createState() => FileBlockComponentState();
}
@@ -253,20 +255,23 @@ class FileBlockComponentState extends State<FileBlockComponent>
if (UniversalPlatform.isDesktopOrWeb) {
if (url == null || url.isEmpty) {
child = DropTarget(
enable: dropManagerState?.isDropEnabled == true ||
dropManagerState?.contains(FileBlockKeys.type) == true,
onDragEntered: (_) {
if (dropManagerState?.isDropEnabled == true) {
dropManagerState?.add(FileBlockKeys.type);
setState(() => isDragging = true);
}
},
onDragExited: (_) {
if (dropManagerState?.isDropEnabled == true) {
if (dropManagerState?.contains(FileBlockKeys.type) == true) {
dropManagerState?.remove(FileBlockKeys.type);
setState(() => isDragging = false);
}
},
onDragDone: (details) {
if (dropManagerState?.isDropEnabled == true) {
insertFileFromLocal(details.files);
}
dropManagerState?.remove(FileBlockKeys.type);
insertFileFromLocal(details.files);
},
child: AppFlowyPopover(
controller: controller,
@@ -277,8 +282,12 @@ class FileBlockComponentState extends State<FileBlockComponent>
minHeight: 80,
),
clickHandler: PopoverClickHandler.gestureDetector,
onOpen: () => dropManagerState?.add(FileBlockKeys.type),
onClose: () => dropManagerState?.remove(FileBlockKeys.type),
onOpen: () => dropManagerState?.add(
FileBlockComponent.uploadDragKey,
),
onClose: () => dropManagerState?.remove(
FileBlockComponent.uploadDragKey,
),
popupBuilder: (_) => FileUploadMenu(
onInsertLocalFile: insertFileFromLocal,
onInsertNetworkFile: insertNetworkFile,
@@ -345,7 +354,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
void _openMenu() {
if (UniversalPlatform.isDesktopOrWeb) {
controller.show();
dropManagerState?.add(FileBlockKeys.type);
dropManagerState?.add(FileBlockComponent.uploadDragKey);
} else {
editorState.updateSelectionWithReason(null, extraInfo: {});
showUploadFileMobileMenu();
@@ -574,7 +583,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
final renderBox = fileKey.currentContext?.findRenderObject();
if (renderBox is RenderBox) {
return Offset.zero & renderBox.size;
return padding.topLeft & renderBox.size;
}
return Rect.zero;
}
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
extension InsertFile on EditorState {
Future<void> insertEmptyFileBlock(GlobalKey key) async {
@@ -17,11 +16,18 @@ extension InsertFile on EditorState {
}
final file = fileNode(url: '')..extraInfos = {'global_key': key};
final insertedPath = delta.isEmpty ? path : path.next;
final transaction = this.transaction
..insertNode(insertedPath, file)
..insertNode(insertedPath, paragraphNode())
..afterSelection = Selection.collapsed(Position(path: insertedPath.next));
final transaction = this.transaction;
// if current node is a paragraph and empty, replace it with the file block
if (delta.isEmpty && node.type == ParagraphBlockKeys.type) {
final insertedPath = path;
transaction.insertNode(insertedPath, file);
transaction.deleteNode(node);
} else {
// otherwise, insert the file block after the current node
final insertedPath = path.next;
transaction.insertNode(insertedPath, file);
}
return apply(transaction);
}
@@ -52,6 +52,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
late final titleFocusNode = editorContext.coverTitleFocusNode;
int lineCount = 1;
bool updatingViewName = false;
@override
void initState() {
super.initState();
@@ -87,7 +89,7 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
final width = context.read<DocumentAppearanceCubit>().state.width;
return BlocConsumer<ViewBloc, ViewState>(
listenWhen: (previous, current) =>
previous.view.name != current.view.name,
previous.view.name != current.view.name && !updatingViewName,
listener: _onListen,
builder: (context, state) {
final appearance = context.read<DocumentAppearanceCubit>().state;
@@ -206,6 +208,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
}
void _onViewNameChanged() {
updatingViewName = true;
Debounce.debounce(
'update view name',
const Duration(milliseconds: 250),
@@ -222,6 +226,8 @@ class _InnerCoverTitleState extends State<_InnerCoverTitle> {
context
.read<ViewInfoBloc?>()
?.add(ViewInfoEvent.titleChanged(titleTextController.text));
updatingViewName = false;
},
);
}
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@@ -44,11 +45,27 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
final documentService = DocumentService();
late final editorState = context.read<EditorState>();
late EditorDropManagerState? dropManagerState = UniversalPlatform.isMobile
? null
: context.read<EditorDropManagerState?>();
bool get isDragEnabled =>
dropManagerState?.isDropEnabled == true ||
dropManagerState?.contains(CustomImageBlockKeys.type) == true;
bool showLoading = false;
String? errorMessage;
bool isDraggingFiles = false;
@override
void didChangeDependencies() {
if (UniversalPlatform.isMobile) {
dropManagerState = context.read<EditorDropManagerState>();
}
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final Widget child = DecoratedBox(
@@ -131,8 +148,15 @@ class ImagePlaceholderState extends State<ImagePlaceholder> {
);
},
child: DropTarget(
onDragEntered: (_) => setState(() => isDraggingFiles = true),
onDragExited: (_) => setState(() => isDraggingFiles = false),
enable: isDragEnabled,
onDragEntered: (_) {
if (isDragEnabled) {
setState(() => isDraggingFiles = true);
}
},
onDragExited: (_) {
setState(() => isDraggingFiles = false);
},
onDragDone: (details) {
// Only accept files where the mimetype is an image,
// otherwise we assume it's a file we cannot display.
@@ -95,7 +95,8 @@ class DateTransactionHandler extends MentionTransactionHandler {
final reminderId = data.$2[MentionBlockKeys.reminderId];
if (reminderId case String _ when reminderId.isNotEmpty) {
getIt<ReminderBloc>().add(ReminderEvent.removeReminder(reminderId: reminderId));
getIt<ReminderBloc>()
.add(ReminderEvent.removeReminder(reminderId: reminderId));
}
}
@@ -146,9 +146,9 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
} else {
final rootContext = widget.editorState.document.root.context;
if (rootContext != null && _reminderId != null) {
rootContext
.read<ReminderBloc?>()
?.add(ReminderEvent.removeReminder(reminderId: _reminderId!));
rootContext.read<ReminderBloc?>()?.add(
ReminderEvent.removeReminder(reminderId: _reminderId!),
);
}
_updateBlock(selectedDay, includeTime: _includeTime);
}
@@ -15,6 +15,7 @@ class CustomPageBlockComponentBuilder extends BlockComponentBuilder {
node: blockComponentContext.node,
header: blockComponentContext.header,
footer: blockComponentContext.footer,
wrapper: blockComponentContext.wrapper,
);
}
}
@@ -29,10 +30,12 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
super.configuration = const BlockComponentConfiguration(),
this.header,
this.footer,
this.wrapper,
});
final Widget? header;
final Widget? footer;
final BlockComponentWrapper? wrapper;
@override
Widget build(BuildContext context) {
@@ -52,14 +55,20 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
children: [
if (header != null) header!,
...items.map(
(e) => Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: editorState.renderer.build(context, e),
),
(e) {
Widget child = editorState.renderer.build(context, e);
if (wrapper != null) {
child = wrapper!(context, node: e, child: child);
}
return Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: child,
);
},
),
if (footer != null) footer!,
],
@@ -92,6 +101,11 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
final childNode = items[index - (header != null ? 1 : 0)];
final isOverflowType = overflowTypes.contains(childNode.type);
Widget child = editorState.renderer.build(context, childNode);
if (wrapper != null) {
child = wrapper!(context, node: childNode, child: child);
}
final item = Container(
constraints: BoxConstraints(
maxWidth: editorState.editorStyle.maxWidth ?? double.infinity,
@@ -99,10 +113,7 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
padding: isOverflowType
? EdgeInsets.zero
: editorState.editorStyle.padding,
child: editorState.renderer.build(
context,
childNode,
),
child: child,
);
return isOverflowType ? item : Center(child: item);
@@ -8,6 +8,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/shared/google_fonts_extension.dart';
import 'package:appflowy/util/font_family_extension.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/appearance_defaults.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
@@ -198,7 +199,11 @@ class EditorStyleCustomizer {
fontSize = state.fontLayout.fontSize;
fontSizes = state.fontLayout.headingFontSizes;
} else {
fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
fontFamily = context
.read<DocumentAppearanceCubit>()
.state
.fontFamily
.orDefault(context.read<AppearanceSettingsCubit>().state.font);
fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
fontSizes = [
fontSize + 16,
@@ -1,8 +1,10 @@
import 'dart:math';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/emoji/emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/emoji_skin_tone.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
@@ -48,6 +50,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
late int startOffset;
late String _search = widget.initialSearchText;
double emojiHeight = 36.0;
String lastSearchedQuery = '';
final configuration = EmojiPickerConfiguration(
defaultSkinTone: lastSelectedEmojiSkinTone ?? EmojiSkinTone.none,
);
@@ -99,7 +102,9 @@ class _EmojiHandlerState extends State<EmojiHandler> {
onKeyEvent: onKeyEvent,
child: Container(
constraints: const BoxConstraints(maxHeight: 392, maxWidth: 360),
padding: const EdgeInsets.symmetric(vertical: 16),
padding: noEmojis
? EdgeInsets.zero
: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.0),
color: Theme.of(context).cardColor,
@@ -111,7 +116,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
),
],
),
child: noEmojis ? buildLoading() : buildEmojis(),
child: loaded ? buildEmojis() : buildLoading(),
),
);
}
@@ -130,6 +135,16 @@ class _EmojiHandlerState extends State<EmojiHandler> {
}
Widget buildEmojis() {
final noEmojis = searchedEmojis.isEmpty;
if (noEmojis) {
return SizedBox(
width: 400,
height: emojiHeight,
child: Center(
child: FlowyText.regular(LocaleKeys.inlineActions_noResults.tr()),
),
);
}
return SizedBox(
height:
(searchedEmojis.length / configuration.perLine).ceil() * emojiHeight,
@@ -208,7 +223,14 @@ class _EmojiHandlerState extends State<EmojiHandler> {
_scrollToItem();
});
if (searchedEmojis.isEmpty) {
widget.onDismiss.call();
if (lastSearchedQuery.isEmpty) {
lastSearchedQuery = _search;
}
if (_search.length - lastSearchedQuery.length >= 5) {
widget.onDismiss.call();
}
} else {
lastSearchedQuery = '';
}
}
@@ -359,7 +381,7 @@ class _EmojiHandlerState extends State<EmojiHandler> {
void _scrollToItem() {
final noEmojis = searchedEmojis.isEmpty;
if (noEmojis || !mounted) return;
if (noEmojis || !mounted || !scrollController.hasClients) return;
final currentItem = selectedIndexNotifier.value;
final exceptHeight = (currentItem ~/ configuration.perLine) * emojiHeight;
final maxExtent = scrollController.position.maxScrollExtent;
@@ -1,10 +1,11 @@
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/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/trash.pb.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:fixnum/fixnum.dart' as $fixnum;
import 'sizes.dart';
@@ -27,7 +28,11 @@ class TrashCell extends StatelessWidget {
children: [
SizedBox(
width: TrashSizes.fileNameWidth,
child: FlowyText(object.name),
child: FlowyText(
object.name.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: object.name,
),
),
SizedBox(
width: TrashSizes.lashModifyWidth,
@@ -121,7 +121,6 @@ class FlowyRunner {
// don't catch errors in test mode
if (!mode.isUnitTest && !mode.isIntegrationTest)
const PlatformErrorCatcherTask(),
if (!mode.isUnitTest) const InitSentryTask(),
// this task should be second task, for handling memory leak.
// there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored.
MemoryLeakDetectorTask(),
@@ -7,6 +7,7 @@ import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/util/font_family_extension.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
@@ -78,7 +79,8 @@ class InitAppWidgetTask extends LaunchTask {
Locale('cs', 'CZ'),
Locale('ckb', 'KU'),
Locale('de', 'DE'),
Locale('en'),
Locale('en', 'US'),
Locale('en', 'GB'),
Locale('es', 'VE'),
Locale('eu', 'ES'),
Locale('el', 'GR'),
@@ -106,7 +108,7 @@ class InitAppWidgetTask extends LaunchTask {
Locale('mr', 'IN'),
],
path: 'assets/translations',
fallbackLocale: const Locale('en'),
fallbackLocale: const Locale('en', 'US'),
useFallbackTranslations: true,
child: app,
),
@@ -137,7 +139,7 @@ class ApplicationWidget extends StatefulWidget {
class _ApplicationWidgetState extends State<ApplicationWidget> {
late final GoRouter routerConfig;
final _commandPaletteNotifier = ValueNotifier<bool>(false);
final _commandPaletteNotifier = ValueNotifier(CommandPaletteNotifierValue());
final themeBuilder = AppFlowyDefaultTheme();
@@ -239,8 +241,9 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
routerConfig: routerConfig,
builder: (context, child) {
final brightness = Theme.of(context).brightness;
final fontFamily =
state.font.orDefault(defaultFontFamily);
final fontFamily = state.font
.orDefault(defaultFontFamily)
.fontFamilyName;
return AppFlowyTheme(
data: brightness == Brightness.light
@@ -13,5 +13,4 @@ export 'platform_error_catcher.dart';
export 'platform_service.dart';
export 'recent_service_task.dart';
export 'rust_sdk.dart';
export 'sentry.dart';
export 'windows.dart';
@@ -1,30 +0,0 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy_backend/log.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../startup.dart';
class InitSentryTask extends LaunchTask {
const InitSentryTask();
@override
Future<void> initialize(LaunchContext context) async {
await super.initialize(context);
const dsn = Env.sentryDsn;
if (dsn.isEmpty) {
Log.info('Sentry DSN is not set, skipping initialization');
return;
}
Log.info('Initializing Sentry');
await SentryFlutter.init(
(options) {
options.dsn = dsn;
options.tracesSampleRate = 0.1;
options.profilesSampleRate = 0.1;
},
);
}
}
@@ -1,11 +1,9 @@
import 'dart:async';
import 'dart:ui';
import 'package:appflowy/core/helpers/helpers.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/app_window_size_manager.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:scaled_app/scaled_app.dart';
import 'package:window_manager/window_manager.dart';
@@ -21,8 +19,14 @@ class InitAppWindowTask extends LaunchTask with WindowListener {
Future<void> initialize(LaunchContext context) async {
await super.initialize(context);
// Don't initialize on mobile or web.
if (!defaultTargetPlatform.isDesktop || context.env.isIntegrationTest) {
// Don't initialize in tests or on web
if (context.env.isTest || UniversalPlatform.isWeb) {
return;
}
if (UniversalPlatform.isMobile) {
final scale = await windowSizeManager.getScaleFactor();
ScaledWidgetsFlutterBinding.instance.scaleFactor = (_) => scale;
return;
}
@@ -50,6 +50,7 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
late final ReminderService _reminderService;
Timer? timer;
late final AppLifecycleListener _listener;
final _deepEquality = DeepCollectionEquality();
bool hasReminder(String reminderId) =>
state.allReminders.where((e) => e.id == reminderId).firstOrNull != null;
@@ -75,9 +76,20 @@ class ReminderBloc extends Bloc<ReminderEvent, ReminderState> {
(reminders) async {
final availableReminders =
await filterAvailableReminders(reminders);
Log.info(
'Fetched reminders on refresh: ${availableReminders.length}',
// only print the reminder ids are not the same as the previous ones
final previousReminderIds =
state.reminders.map((e) => e.id).toSet();
final newReminderIds =
availableReminders.map((e) => e.id).toSet();
final diff = _deepEquality.equals(
previousReminderIds,
newReminderIds,
);
if (!diff) {
Log.info(
'Fetched reminders on refresh: ${availableReminders.length}',
);
}
if (!isClosed && !emit.isDone) {
emit(
state.copyWith(
@@ -1,4 +1,5 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/google_fonts_extension.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -12,4 +13,11 @@ extension FontFamilyExtension on String {
String get fontFamilyDisplayName => isEmpty || this == defaultFontFamily
? LocaleKeys.settings_appearance_fontFamily_defaultFont.tr()
: parseFontFamilyName();
// the font display name is not the same as the font family name
// for example, the display name is "Noto Sans HK"
// the font family name is "NotoSansHK_Regular"
String get fontFamilyName => isEmpty || this == defaultFontFamily
? defaultFontFamily
: getGoogleFontSafely(this).fontFamily ?? defaultFontFamily;
}
@@ -49,7 +49,6 @@ class CommandPaletteBloc
final TrashService _trashService = TrashService();
final TrashListener _trashListener = TrashListener();
String? _activeQuery;
String? _workspaceId;
@override
Future<void> close() {
@@ -115,7 +114,6 @@ class CommandPaletteBloc
unawaited(
SearchBackendService.performSearch(
event.search,
workspaceId: _workspaceId,
).then(
(result) => result.fold(
(stream) {
@@ -161,6 +159,7 @@ class CommandPaletteBloc
onServerItems: (items, searchId, searching, generatingAIOverview) =>
_handleResultsUpdate(
searchId: searchId,
summaries: [], // when got server search result, summaries should be empty
serverItems: items,
searching: searching,
generatingAIOverview: generatingAIOverview,
@@ -263,7 +262,6 @@ class CommandPaletteBloc
_WorkspaceChanged event,
Emitter<CommandPaletteState> emit,
) {
_workspaceId = event.workspaceId;
emit(
state.copyWith(
query: '',
@@ -288,14 +286,14 @@ class CommandPaletteBloc
_GoingToAskAI event,
Emitter<CommandPaletteState> emit,
) {
emit(state.copyWith(askAI: true));
emit(state.copyWith(askAI: true, askAISources: event.sources));
}
FutureOr<void> _onAskedAI(
_AskedAI event,
Emitter<CommandPaletteState> emit,
) {
emit(state.copyWith(askAI: false));
emit(state.copyWith(askAI: false, askAISources: null));
}
bool _isActiveSearch(String searchId) =>
@@ -327,7 +325,9 @@ class CommandPaletteEvent with _$CommandPaletteEvent {
@Default(null) String? workspaceId,
}) = _WorkspaceChanged;
const factory CommandPaletteEvent.clearSearch() = _ClearSearch;
const factory CommandPaletteEvent.gointToAskAI() = _GoingToAskAI;
const factory CommandPaletteEvent.goingToAskAI({
@Default(null) List<SearchSourcePB>? sources,
}) = _GoingToAskAI;
const factory CommandPaletteEvent.askedAI() = _AskedAI;
}
@@ -360,6 +360,7 @@ class CommandPaletteState with _$CommandPaletteState {
required bool searching,
required bool generatingAIOverview,
@Default(false) bool askAI,
@Default(null) List<SearchSourcePB>? askAISources,
@Default([]) List<TrashPB> trash,
@Default(null) String? searchId,
}) = _CommandPaletteState;
@@ -19,7 +19,8 @@ extension GetIcon on ResultIconPB {
lineHeight: lineHeight,
)
: null;
} else if (ty == ResultIconTypePB.Icon) {
} else if (iconType == ResultIconTypePB.Icon ||
iconType == ResultIconTypePB.Url) {
if (_resultIconValueTypes.contains(iconValue)) {
return FlowySvg(
getViewSvg(),
@@ -8,28 +8,23 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
import 'package:fixnum/fixnum.dart';
class SearchBackendService {
static Future<FlowyResult<SearchResponseStream, FlowyError>> performSearch(
String keyword, {
String? workspaceId,
}) async {
final searchId = nanoid(6);
String keyword,
) async {
final searchId = DateTime.now().millisecondsSinceEpoch.toString();
final stream = SearchResponseStream(searchId: searchId);
final filter = SearchFilterPB(workspaceId: workspaceId);
final request = SearchQueryPB(
search: keyword,
filter: filter,
searchId: searchId,
streamPort: Int64(stream.nativePort),
);
unawaited(SearchEventSearch(request).send());
unawaited(SearchEventStreamSearch(request).send());
return FlowyResult.success(stream);
}
}
@@ -46,6 +46,11 @@ class RecentViewsBloc extends Bloc<RecentViewsEvent, RecentViewsState> {
await _service.reset();
add(const RecentViewsEvent.fetchRecentViews());
},
hoverView: (e) async {
emit(
state.copyWith(hoveredView: e.view),
);
},
);
},
);
@@ -64,6 +69,7 @@ class RecentViewsEvent with _$RecentViewsEvent {
RemoveRecentViews;
const factory RecentViewsEvent.fetchRecentViews() = FetchRecentViews;
const factory RecentViewsEvent.resetRecentViews() = ResetRecentViews;
const factory RecentViewsEvent.hoverView(ViewPB view) = HoverView;
}
@freezed
@@ -71,6 +77,7 @@ class RecentViewsState with _$RecentViewsState {
const factory RecentViewsState({
required List<SectionViewPB> views,
@Default(true) bool isLoading,
@Default(null) ViewPB? hoveredView,
}) = _RecentViewsState;
factory RecentViewsState.initial() => const RecentViewsState(views: []);
@@ -39,8 +39,7 @@ class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
emit(
LocalAiPluginState.ready(
isEnabled: aiState.enabled,
version: aiState.pluginVersion,
runningState: aiState.state,
isReady: aiState.isReady,
lackOfResource:
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
),
@@ -116,8 +115,7 @@ class LocalAiPluginState with _$LocalAiPluginState {
const factory LocalAiPluginState.ready({
required bool isEnabled,
required String version,
required RunningStatePB runningState,
required bool isReady,
required LackOfAIResourcePB? lackOfResource,
}) = ReadyLocalAiPluginState;
@@ -125,15 +123,15 @@ class LocalAiPluginState with _$LocalAiPluginState {
bool get isEnabled {
return maybeWhen(
ready: (isEnabled, _, __, ___) => isEnabled,
ready: (isEnabled, _, ___) => isEnabled,
orElse: () => false,
);
}
bool get showIndicator {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) =>
runningState != RunningStatePB.Running || lackOfResource != null,
ready: (isEnabled, isReady, lackOfResource) =>
isReady || lackOfResource != null,
orElse: () => false,
);
}
@@ -150,14 +150,16 @@ class SettingItem extends Equatable {
required this.content,
required this.hintText,
required this.settingType,
this.editable = true,
});
final String content;
final String hintText;
final SettingType settingType;
final bool editable;
@override
List<Object?> get props => [content, settingType];
List<Object?> get props => [content, settingType, editable];
}
/// Items pending submission
@@ -232,6 +234,7 @@ extension on LocalAISettingPB {
content: embeddingModelName,
hintText: kDefaultEmbeddingModel,
settingType: SettingType.embeddingModel,
editable: false, // embedding model is not editable
),
];
@@ -11,7 +11,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'settings_ai_bloc.freezed.dart';
const String aiModelsGlobalActiveModel = "ai_models_global_active_model";
const String aiModelsGlobalActiveModel = "global_active_model";
class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
SettingsAIBloc(
@@ -75,9 +75,6 @@ class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
);
},
selectModel: (AIModelPB model) async {
if (!model.isLocal) {
await _updateUserWorkspaceSetting(model: model.name);
}
await AIEventUpdateSelectedModel(
UpdateSelectedModelPB(
source: aiModelsGlobalActiveModel,
@@ -201,7 +201,7 @@ class AppearanceSettingsCubit extends Cubit<AppearanceSettingsState> {
void setLocale(BuildContext context, Locale newLocale) {
if (!context.supportedLocales.contains(newLocale)) {
// Log.warn("Unsupported locale: $newLocale, Fallback to locale: en");
newLocale = const Locale('en');
newLocale = const Locale('en', 'US');
}
context.setLocale(newLocale).catchError((e) {
@@ -194,8 +194,18 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
).getWorkspaceUsage().then((result) {
result.fold(
(usage) {
if (!isClosed && usage != null) {
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
if (!isClosed) {
// if the user cannot fetch the workspace usage,
// clear the tier indicator
if (usage == null) {
add(
const SidebarPlanEvent.updateTierIndicator(
SidebarToastTierIndicator.loading(),
),
);
} else {
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
}
}
},
(error) => Log.error("Failed to get workspace usage: $error"),
File diff suppressed because it is too large. Load diff
@@ -298,6 +298,21 @@ extension ViewExtension on ViewPB {
return PageStyleFontLayout.normal;
}
}
@visibleForTesting
set isSpace(bool value) {
try {
if (extra.isEmpty) {
extra = jsonEncode({ViewExtKeys.isSpaceKey: value});
} else {
final ext = jsonDecode(extra);
ext[ViewExtKeys.isSpaceKey] = value;
extra = jsonEncode(ext);
}
} catch (e) {
extra = jsonEncode({ViewExtKeys.isSpaceKey: value});
}
}
}
extension ViewLayoutExtension on ViewLayoutPB {
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
@@ -86,17 +85,6 @@ class WorkspaceService {
}
Future<FlowyResult<WorkspaceUsagePB?, FlowyError>> getWorkspaceUsage() async {
final request = WorkspaceMemberIdPB()..uid = userId;
final result = await UserEventGetMemberInfo(request).send();
final isOwner = result.fold(
(member) => member.role.isOwner,
(_) => false,
);
if (!isOwner) {
return FlowyResult.success(null);
}
final payload = UserWorkspaceIdPB(workspaceId: workspaceId);
return UserEventGetWorkspaceUsage(payload).send();
}
@@ -1,15 +1,29 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import 'widgets/search_ask_ai_entrance.dart';
class CommandPalette extends InheritedWidget {
CommandPalette({
super.key,
@@ -19,7 +33,7 @@ class CommandPalette extends InheritedWidget {
child: _CommandPaletteController(notifier: notifier, child: child),
);
final ValueNotifier<bool> notifier;
final ValueNotifier<CommandPaletteNotifierValue> notifier;
static CommandPalette of(BuildContext context) {
final CommandPalette? result =
@@ -30,7 +44,30 @@ class CommandPalette extends InheritedWidget {
return result!;
}
void toggle() => notifier.value = !notifier.value;
static CommandPalette? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<CommandPalette>();
void toggle({
UserWorkspaceBloc? workspaceBloc,
SpaceBloc? spaceBloc,
}) {
final value = notifier.value;
notifier.value = notifier.value.copyWith(
isOpen: !value.isOpen,
userWorkspaceBloc: workspaceBloc,
spaceBloc: spaceBloc,
);
}
void updateBlocs({
UserWorkspaceBloc? workspaceBloc,
SpaceBloc? spaceBloc,
}) {
notifier.value = notifier.value.copyWith(
userWorkspaceBloc: workspaceBloc,
spaceBloc: spaceBloc,
);
}
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) => false;
@@ -47,7 +84,7 @@ class _CommandPaletteController extends StatefulWidget {
});
final Widget? child;
final ValueNotifier<bool> notifier;
final ValueNotifier<CommandPaletteNotifierValue> notifier;
@override
State<_CommandPaletteController> createState() =>
@@ -55,7 +92,8 @@ class _CommandPaletteController extends StatefulWidget {
}
class _CommandPaletteControllerState extends State<_CommandPaletteController> {
late ValueNotifier<bool> _toggleNotifier = widget.notifier;
late ValueNotifier<CommandPaletteNotifierValue> _toggleNotifier =
widget.notifier;
bool _isOpen = false;
@override
@@ -81,19 +119,28 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
}
void _onToggle() {
if (_toggleNotifier.value && !_isOpen) {
if (_toggleNotifier.value.isOpen && !_isOpen) {
_isOpen = true;
final workspaceBloc = _toggleNotifier.value.userWorkspaceBloc;
final spaceBloc = _toggleNotifier.value.spaceBloc;
Log.info(
'CommandPalette onToggle: workspaceType ${workspaceBloc?.state.userProfile.workspaceType}',
);
FlowyOverlay.show(
context: context,
builder: (_) => BlocProvider.value(
value: context.read<CommandPaletteBloc>(),
builder: (_) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<CommandPaletteBloc>()),
if (workspaceBloc != null) BlocProvider.value(value: workspaceBloc),
if (spaceBloc != null) BlocProvider.value(value: spaceBloc),
],
child: CommandPaletteModal(shortcutBuilder: _buildShortcut),
),
).then((_) {
_isOpen = false;
_toggleNotifier.value = false;
_toggleNotifier.value = _toggleNotifier.value.copyWith(isOpen: false);
});
} else if (!_toggleNotifier.value && _isOpen) {
} else if (!_toggleNotifier.value.isOpen && _isOpen) {
FlowyOverlay.pop(context);
_isOpen = false;
}
@@ -107,8 +154,8 @@ class _CommandPaletteControllerState extends State<_CommandPaletteController> {
actions: {
_ToggleCommandPaletteIntent:
CallbackAction<_ToggleCommandPaletteIntent>(
onInvoke: (intent) =>
_toggleNotifier.value = !_toggleNotifier.value,
onInvoke: (intent) => _toggleNotifier.value = _toggleNotifier.value
.copyWith(isOpen: !_toggleNotifier.value.isOpen),
),
},
shortcuts: {
@@ -130,68 +177,155 @@ class CommandPaletteModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
builder: (context, state) => FlowyDialog(
alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 100),
constraints: const BoxConstraints(
maxHeight: 600,
maxWidth: 800,
minHeight: 600,
),
expandHeight: false,
child: shortcutBuilder(
// Change mainAxisSize to max so Expanded works correctly.
Column(
children: [
SearchField(query: state.query, isLoading: state.searching),
if (state.query?.isEmpty ?? true) ...[
const Divider(height: 0),
Flexible(
child: RecentViewsList(
onSelected: () => FlowyOverlay.pop(context),
),
),
],
if (state.combinedResponseItems.isNotEmpty &&
(state.query?.isNotEmpty ?? false)) ...[
const Divider(height: 0),
Flexible(
child: SearchResultList(
trash: state.trash,
resultItems: state.combinedResponseItems.values.toList(),
resultSummaries: state.resultSummaries,
),
),
]
// When there are no results and the query is not empty and not loading,
// show the no results message, centered in the available space.
else if ((state.query?.isNotEmpty ?? false) &&
!state.searching) ...[
const Divider(height: 0),
Expanded(
child: const _NoResultsHint(),
),
],
],
),
),
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
final showAskingAI =
workspaceState?.userProfile.workspaceType == WorkspaceTypePB.ServerW;
return BlocListener<CommandPaletteBloc, CommandPaletteState>(
listener: (_, state) {
if (state.askAI && context.mounted) {
if (Navigator.canPop(context)) FlowyOverlay.pop(context);
final currentWorkspace = workspaceState?.workspaces;
final spaceBloc = context.read<SpaceBloc?>();
if (currentWorkspace != null && spaceBloc != null) {
spaceBloc.add(
SpaceEvent.createPage(
name: '',
layout: ViewLayoutPB.Chat,
index: 0,
openAfterCreate: true,
),
);
}
}
},
child: BlocBuilder<CommandPaletteBloc, CommandPaletteState>(
builder: (context, state) {
final noQuery = state.query?.isEmpty ?? true, hasQuery = !noQuery;
final hasResult = state.combinedResponseItems.isNotEmpty;
return FlowyDialog(
alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 100),
constraints: const BoxConstraints(
maxHeight: 640,
maxWidth: 900,
minHeight: 640,
),
expandHeight: false,
child: shortcutBuilder(
// Change mainAxisSize to max so Expanded works correctly.
Column(
children: [
SearchField(query: state.query, isLoading: state.searching),
if (noQuery)
Flexible(
child: RecentViewsList(
onSelected: () => FlowyOverlay.pop(context),
),
),
if (hasResult && hasQuery) ...[
AFDivider(),
Flexible(
child: SearchResultList(
trash: state.trash,
resultItems:
state.combinedResponseItems.values.toList(),
resultSummaries: state.resultSummaries,
),
),
]
// When there are no results and the query is not empty and not loading,
// show the no results message, centered in the available space.
else if (hasQuery && !state.searching) ...[
AFDivider(),
if (showAskingAI) SearchAskAiEntrance(),
Expanded(
child: const NoSearchResultsHint(),
),
],
],
),
),
);
},
),
);
}
}
/// Updated _NoResultsHint now centers its content.
class _NoResultsHint extends StatelessWidget {
const _NoResultsHint();
class NoSearchResultsHint extends StatelessWidget {
const NoSearchResultsHint({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.secondary;
return Center(
child: FlowyText.regular(
LocaleKeys.commandPalette_noResultsHint.tr(),
textAlign: TextAlign.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
const VSpace(8),
Text(
LocaleKeys.search_noResultForSearching.tr(),
style: theme.textStyle.body.enhanced(color: textColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const VSpace(4),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
text: LocaleKeys.search_noResultForSearchingHintWithoutTrash.tr(),
style: theme.textStyle.caption.standard(color: textColor),
children: [
TextSpan(
text: LocaleKeys.trash_text.tr(),
style: theme.textStyle.caption.underline(color: textColor),
recognizer: TapGestureRecognizer()
..onTap = () {
FlowyOverlay.pop(context);
getIt<MenuSharedState>().latestOpenView = null;
getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: makePlugin(pluginType: PluginType.trash),
),
);
},
),
],
),
),
],
),
);
}
}
class CommandPaletteNotifierValue {
CommandPaletteNotifierValue({
this.isOpen = false,
this.userWorkspaceBloc,
this.spaceBloc,
});
final bool isOpen;
final UserWorkspaceBloc? userWorkspaceBloc;
final SpaceBloc? spaceBloc;
CommandPaletteNotifierValue copyWith({
bool? isOpen,
UserWorkspaceBloc? userWorkspaceBloc,
SpaceBloc? spaceBloc,
}) {
return CommandPaletteNotifierValue(
isOpen: isOpen ?? this.isOpen,
userWorkspaceBloc: userWorkspaceBloc ?? this.userWorkspaceBloc,
spaceBloc: spaceBloc ?? this.spaceBloc,
);
}
}
@@ -0,0 +1,209 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/search/mobile_view_ancestors.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PagePreview extends StatelessWidget {
const PagePreview({
super.key,
required this.view,
required this.onViewOpened,
});
final ViewPB view;
final VoidCallback onViewOpened;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 24,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox.square(
dimension: 24,
child: Center(child: buildIcon(theme, view)),
),
VSpace(8),
buildTitle(context, view),
buildPath(context, view),
...buildTime(
context,
LocaleKeys.commandPalette_created.tr(),
view.createTime.toDateTime(),
),
if (view.lastEdited != view.createTime)
...buildTime(
context,
LocaleKeys.commandPalette_edited.tr(),
view.lastEdited.toDateTime(),
),
],
),
),
);
}
Widget buildIcon(AppFlowyThemeData theme, ViewPB view) {
return view.icon.value.isNotEmpty
? RawEmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: 20.0,
lineHeight: 1,
)
: FlowySvg(
view.iconData,
size: const Size.square(20),
color: theme.iconColorScheme.secondary,
);
}
Widget buildTitle(BuildContext context, ViewPB view) {
final theme = AppFlowyTheme.of(context);
final titleStyle = theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.primary),
titleHoverStyle =
titleStyle.copyWith(decoration: TextDecoration.underline);
return SizedBox(
height: 28,
child: Row(
children: [
Flexible(
child: AFBaseButton(
padding: EdgeInsets.zero,
builder: (context, isHovering, disabled) =>
SelectionContainer.disabled(
child: Text(
view.nameOrDefault,
style: isHovering ? titleHoverStyle : titleStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
borderColor: (_, __, ___, ____) => Colors.transparent,
borderRadius: 0,
onTap: onViewOpened,
),
),
HSpace(4),
FlowyTooltip(
message: LocaleKeys.settings_files_open.tr(),
child: AFGhostButton.normal(
size: AFButtonSize.s,
padding: EdgeInsets.all(theme.spacing.xs),
onTap: onViewOpened,
builder: (context, isHovering, disabled) => FlowySvg(
FlowySvgs.search_arrow_right_m,
size: const Size.square(20),
),
),
),
],
),
);
}
Widget buildPath(BuildContext context, ViewPB view) {
final theme = AppFlowyTheme.of(context);
return BlocProvider(
key: ValueKey(view.id),
create: (context) => ViewAncestorBloc(view.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final isEmpty = state.ancestor.ancestors.isEmpty;
if (!state.isLoading && isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(16),
Text(
LocaleKeys.commandPalette_location.tr(),
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.primary),
),
state.buildPath(
context,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
),
],
);
},
),
);
}
List<Widget> buildTime(BuildContext context, String title, DateTime time) {
final theme = AppFlowyTheme.of(context);
final appearanceSettings = context.watch<AppearanceSettingsCubit>().state;
final dateFormat = appearanceSettings.dateFormat,
timeFormat = appearanceSettings.timeFormat;
return [
VSpace(12),
Text(
title,
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.primary),
),
Text(
dateFormat.formatDate(time, true, timeFormat),
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.secondary),
),
];
}
}
class SomethingWentWrong extends StatelessWidget {
const SomethingWentWrong({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.something_wrong_warning_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
const VSpace(8),
Text(
LocaleKeys.search_somethingWentWrong.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const VSpace(4),
Text(
LocaleKeys.search_tryAgainOrLater.tr(),
style: theme.textStyle.caption
.standard(color: theme.textColorScheme.secondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -2,15 +2,24 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'page_preview.dart';
import 'search_ask_ai_entrance.dart';
class RecentViewsList extends StatelessWidget {
const RecentViewsList({super.key, required this.onSelected});
@@ -26,39 +35,133 @@ class RecentViewsList extends StatelessWidget {
// We remove duplicates by converting the list to a set first
final List<ViewPB> recentViews =
state.views.map((e) => e.item).toSet().toList();
final theme = AppFlowyTheme.of(context);
final workspaceState = context.read<UserWorkspaceBloc?>()?.state;
final showAskingAI = workspaceState?.userProfile.workspaceType ==
WorkspaceTypePB.ServerW;
final hoveredView = state.hoveredView;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
AFDivider(),
Flexible(
child: Row(
children: [
Flexible(
flex: 2,
child: Align(
alignment: Alignment.topLeft,
child: ScrollControllerBuilder(
builder: (context, controller) {
return FlowyScrollbar(
controller: controller,
child: SingleChildScrollView(
controller: controller,
physics: const ClampingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showAskingAI) SearchAskAiEntrance(),
Container(
height: 20,
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
margin: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(
LocaleKeys.sideBar_recent.tr(),
style: theme.textStyle.body.enhanced(
color:
theme.textColorScheme.secondary,
),
),
),
ListView.separated(
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
itemCount: recentViews.length,
padding:
EdgeInsets.symmetric(horizontal: 12),
itemBuilder: (_, index) {
final view = recentViews[index];
return ListView.separated(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: recentViews.length + 1,
itemBuilder: (_, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: FlowyText(
LocaleKeys.commandPalette_recentHistory.tr(),
),
);
}
final icon = view.icon.value.isNotEmpty
? RawEmojiIconWidget(
emoji:
view.icon.toEmojiIconData(),
emojiSize: 16.0,
lineHeight: 20 / 16,
)
: FlowySvg(
view.iconData,
size: const Size.square(20),
color: theme
.iconColorScheme.secondary,
);
final view = recentViews[index - 1];
final icon = view.icon.value.isNotEmpty
? EmojiIconWidget(
emoji: view.icon.toEmojiIconData(),
emojiSize: 18.0,
)
: FlowySvg(view.iconData, size: const Size.square(20));
return SearchRecentViewCell(
icon: SizedBox(width: 24, child: icon),
view: view,
onSelected: onSelected,
);
},
separatorBuilder: (_, __) => const Divider(height: 0),
return SearchRecentViewCell(
icon: SizedBox.square(
dimension: 24,
child: Center(child: icon),
),
view: view,
onSelected: onSelected,
);
},
separatorBuilder: (_, index) {
final view = recentViews[index];
final isHovered =
hoveredView?.id == view.id;
if (isHovered) return VSpace(1);
if (index < recentViews.length - 1) {
final nextView =
recentViews[index + 1];
final isNextHovered =
hoveredView?.id == nextView.id;
if (isNextHovered) return VSpace(1);
}
return const AFDivider();
},
),
VSpace(8),
],
),
),
);
},
),
),
),
if (hoveredView != null) ...[
AFDivider(axis: Axis.vertical),
Flexible(
child: Align(
alignment: Alignment.topLeft,
child: PagePreview(
view: hoveredView,
onViewOpened: () {
getIt<ActionNavigationBloc>().add(
ActionNavigationEvent.performAction(
action: NavigationAction(
objectId: hoveredView.id,
),
),
);
onSelected();
},
),
),
),
],
],
),
),
],
);
},
),
@@ -0,0 +1,227 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'search_summary_cell.dart';
class SearchAskAiEntrance extends StatelessWidget {
const SearchAskAiEntrance({super.key});
@override
Widget build(BuildContext context) {
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
final generatingAIOverview = state?.generatingAIOverview ?? false;
final hasAIOverview =
_mockSummary?.isNotEmpty ?? state?.resultSummaries.isNotEmpty ?? false;
if (generatingAIOverview) {
return _AISearching();
} else if (hasAIOverview) {
return _AIOverview();
}
return _AskAIFor();
}
}
class _AskAIFor extends StatelessWidget {
const _AskAIFor();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final sapceM = theme.spacing.m, spaceL = theme.spacing.l;
return Padding(
padding: EdgeInsets.fromLTRB(spaceL, sapceM, spaceL, 0),
child: AFBaseButton(
borderRadius: sapceM,
padding: EdgeInsets.symmetric(vertical: spaceL, horizontal: sapceM),
backgroundColor: (context, isHovering, disable) {
if (isHovering) {
return Theme.of(context).colorScheme.secondary;
}
return theme.fillColorScheme.transparent;
},
borderColor: (context, isHovering, disable, isFocused) =>
theme.fillColorScheme.transparent,
builder: (ctx, isHovering, disable) {
return Row(
children: [
FlowySvg(
FlowySvgs.m_home_ai_chat_icon_m,
size: Size.square(20),
blendMode: null,
),
HSpace(12),
buildText(context),
],
);
},
onTap: () {
context
.read<CommandPaletteBloc?>()
?.add(CommandPaletteEvent.goingToAskAI());
},
),
);
}
Widget buildText(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final bloc = context.read<CommandPaletteBloc?>();
final queryText = bloc?.state.query ?? '';
if (queryText.isEmpty) {
return Text(
LocaleKeys.search_askAIAnything.tr(),
style:
theme.textStyle.body.standard(color: theme.textColorScheme.primary),
);
}
return Flexible(
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.search_askAIFor.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.primary),
),
TextSpan(
text: ' "$queryText"',
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.primary),
),
],
),
),
);
}
}
class _AISearching extends StatelessWidget {
const _AISearching();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 8),
child: SizedBox(
height: 22,
child: Row(
children: [
FlowySvg(
FlowySvgs.ai_searching_icon_m,
size: Size.square(20),
blendMode: null,
),
HSpace(8),
Text(
LocaleKeys.search_searching.tr(),
style: theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.secondary),
),
],
),
),
);
}
}
class _AIOverview extends StatelessWidget {
const _AIOverview();
@override
Widget build(BuildContext context) {
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
final theme = AppFlowyTheme.of(context);
final summaries = _mockSummary ?? state?.resultSummaries ?? [];
if (summaries.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(context),
VSpace(8),
LayoutBuilder(
builder: (context, constrains) {
final summary = summaries.first;
return SearchSummaryCell(
key: ValueKey(summary.content.trim()),
summary: summary,
maxWidth: constrains.maxWidth,
theme: AppFlowyTheme.of(context),
);
},
),
VSpace(8),
SizedBox(
width: 143,
child: AFOutlinedButton.normal(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
borderRadius: 16,
builder: (context, hovering, disabled) {
return Row(
children: [
FlowySvg(
FlowySvgs.chat_ai_page_s,
size: Size.square(20),
color: theme.iconColorScheme.primary,
),
HSpace(8),
Text(
LocaleKeys.commandPalette_aiAskFollowUp.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
],
);
},
onTap: () {
context.read<CommandPaletteBloc?>()?.add(
CommandPaletteEvent.goingToAskAI(
sources: summaries.first.sources,
),
);
},
),
),
],
),
);
}
Widget buildHeader(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
height: 22,
child: Row(
children: [
FlowySvg(
FlowySvgs.ai_searching_icon_m,
size: Size.square(20),
blendMode: null,
),
HSpace(8),
Text(
LocaleKeys.commandPalette_aiOverview.tr(),
style: theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.primary),
),
],
),
);
}
}
List<SearchSummaryPB>? _mockSummary;
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff. Show more